Merge branch 'master' into LGT

This commit is contained in:
nikk gitanes
2021-07-18 01:13:11 +03:00
106 changed files with 15172 additions and 52101 deletions

View File

@@ -0,0 +1,17 @@
import { GitHub as GitHubIcon } from '@material-ui/icons'
import { LinkWrapper, LinkIcon } from './style'
export default function LinkComponent({ name, link }) {
return (
<LinkWrapper isLink={!!link} href={link} target='_blank' rel='noreferrer'>
{link && (
<LinkIcon>
<GitHubIcon />
</LinkIcon>
)}
<div>{name}</div>
</LinkWrapper>
)
}

View File

@@ -0,0 +1,82 @@
import axios from 'axios'
import { useEffect, useState } from 'react'
import Button from '@material-ui/core/Button'
import Dialog from '@material-ui/core/Dialog'
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 { useMediaQuery } from '@material-ui/core'
import { echoHost } from 'utils/Hosts'
import LinkComponent from './LinkComponent'
import { DialogWrapper, HeaderSection, ThanksSection, Section, FooterSection } from './style'
export default function AboutDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [torrServerVersion, setTorrServerVersion] = useState('')
const fullScreen = useMediaQuery('@media (max-width:930px)')
useEffect(() => {
axios.get(echoHost()).then(({ data }) => setTorrServerVersion(data))
}, [])
return (
<>
<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'
fullScreen={fullScreen}
maxWidth='xl'
>
<DialogWrapper>
<HeaderSection>
<div>{t('About')}</div>
{torrServerVersion}
<img src='/apple-touch-icon.png' alt='ts-icon' />
</HeaderSection>
<div style={{ overflow: 'auto' }}>
<ThanksSection>{t('ThanksToEveryone')}</ThanksSection>
<Section>
<span>{t('Links')}</span>
<div>
<LinkComponent name={t('ProjectSource')} link='https://github.com/YouROK/TorrServer' />
<LinkComponent name={t('Releases')} link='https://github.com/YouROK/TorrServer/releases' />
</div>
</Section>
<Section>
<span>{t('SpecialThanks')}</span>
<div>
<LinkComponent name='Daniel Shleifman' link='https://github.com/dancheskus' />
<LinkComponent name='Matt Joiner' link='https://github.com/anacrolix' />
<LinkComponent name='nikk' link='https://github.com/tsynik' />
<LinkComponent name='tw1cker Руслан Пахнев' link='https://github.com/Nemiroff' />
<LinkComponent name='SpAwN_LMG' link='https://github.com/spawnlmg' />
</div>
</Section>
</div>
<FooterSection>
<Button onClick={() => setOpen(false)} color='primary' variant='contained'>
{t('Close')}
</Button>
</FooterSection>
</DialogWrapper>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,121 @@
import styled, { css } from 'styled-components'
export const DialogWrapper = styled.div`
height: 100%;
display: grid;
grid-template-rows: max-content 1fr max-content;
`
export const HeaderSection = styled.section`
display: flex;
justify-content: space-between;
align-items: center;
font-size: 36px;
font-weight: 300;
padding: 20px;
img {
width: 64px;
}
@media (max-width: 930px) {
font-size: 24px;
padding: 10px 20px;
img {
width: 60px;
}
}
`
export const ThanksSection = styled.section`
padding: 20px;
text-align: center;
font-size: 24px;
font-weight: 300;
background: #e8e5eb;
color: #323637;
@media (max-width: 930px) {
font-size: 20px;
padding: 30px 20px;
}
`
export const Section = styled.section`
padding: 20px;
> span {
font-size: 20px;
display: block;
margin-bottom: 15px;
}
a {
text-decoration: none;
}
> div {
display: grid;
gap: 10px;
grid-template-columns: repeat(4, max-content);
@media (max-width: 930px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 780px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 550px) {
grid-template-columns: 1fr;
}
}
`
export const FooterSection = styled.div`
padding: 20px;
display: flex;
justify-content: flex-end;
background: #e8e5eb;
`
export const LinkWrapper = styled.a`
${({ isLink }) => css`
display: inline-flex;
align-items: center;
justify-content: start;
border: 1px solid;
padding: 10px;
border-radius: 5px;
text-transform: uppercase;
text-decoration: none;
background: #545a5e;
color: #f1eff3;
transition: 0.2s;
> * {
transition: 0.2s;
}
${isLink
? css`
:hover {
filter: brightness(1.1);
> * {
transform: translateY(-1px);
}
}
`
: css`
cursor: default;
`}
`}
`
export const LinkIcon = styled.div`
display: grid;
margin-right: 10px;
`

View File

@@ -1,55 +1,281 @@
import { useState } from 'react'
import { useCallback, useEffect, useMemo, 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 { torrentsHost, torrentUploadHost } from 'utils/Hosts'
import axios from 'axios'
import { useTranslation } from 'react-i18next'
import debounce from 'lodash/debounce'
import useChangeLanguage from 'utils/useChangeLanguage'
import { useMediaQuery } from '@material-ui/core'
import CircularProgress from '@material-ui/core/CircularProgress'
import usePreviousState from 'utils/usePreviousState'
import { useQuery } from 'react-query'
import { getTorrents } from 'utils/Utils'
import parseTorrent from 'parse-torrent'
import { ButtonWrapper, Header } from 'style/DialogStyles'
export default function AddDialog({ handleClose }) {
import { checkImageURL, getMoviePosters, chechTorrentSource, parseTorrentTitle } from './helpers'
import { Content } from './style'
import RightSideComponent from './RightSideComponent'
import LeftSideComponent from './LeftSideComponent'
export default function AddDialog({
handleClose,
hash: originalHash,
title: originalTitle,
name: originalName,
poster: originalPoster,
}) {
const { t } = useTranslation()
const [link, setLink] = useState('')
const [title, setTitle] = useState('')
const [poster, setPoster] = useState('')
const isEditMode = !!originalHash
const [torrentSource, setTorrentSource] = useState(originalHash || '')
const [title, setTitle] = useState(originalTitle || '')
const [originalTorrentTitle, setOriginalTorrentTitle] = useState('')
const [parsedTitle, setParsedTitle] = useState('')
const [posterUrl, setPosterUrl] = useState(originalPoster || '')
const [isPosterUrlCorrect, setIsPosterUrlCorrect] = useState(false)
const [isTorrentSourceCorrect, setIsTorrentSourceCorrect] = useState(false)
const [isHashAlreadyExists, setIsHashAlreadyExists] = useState(false)
const [posterList, setPosterList] = useState()
const [isUserInteractedWithPoster, setIsUserInteractedWithPoster] = useState(isEditMode)
const [currentLang] = useChangeLanguage()
const [selectedFile, setSelectedFile] = useState()
const [posterSearchLanguage, setPosterSearchLanguage] = useState(currentLang === 'ru' ? 'ru' : 'en')
const [isSaving, setIsSaving] = useState(false)
const [skipDebounce, setSkipDebounce] = useState(false)
const [isCustomTitleEnabled, setIsCustomTitleEnabled] = useState(false)
const [currentSourceHash, setCurrentSourceHash] = useState()
const inputMagnet = ({ target: { value } }) => setLink(value)
const inputTitle = ({ target: { value } }) => setTitle(value)
const inputPoster = ({ target: { value } }) => setPoster(value)
const { data: torrents } = useQuery('torrents', getTorrents, { retry: 1, refetchInterval: 1000 })
useEffect(() => {
// getting hash from added torrent source
parseTorrent.remote(selectedFile || torrentSource, (_, { infoHash } = {}) => setCurrentSourceHash(infoHash))
}, [selectedFile, torrentSource])
useEffect(() => {
// checking if torrent already exists in DB
if (!setCurrentSourceHash) return
const allHashes = torrents.map(({ hash }) => hash)
setIsHashAlreadyExists(allHashes.includes(currentSourceHash))
}, [currentSourceHash, torrents])
useEffect(() => {
// closing dialog when torrent successfully added in DB
if (!isSaving) return
const allHashes = torrents.map(({ hash }) => hash)
allHashes.includes(currentSourceHash) && handleClose()
}, [isSaving, torrents, currentSourceHash, handleClose])
const fullScreen = useMediaQuery('@media (max-width:930px)')
const updateTitleFromSource = useCallback(() => {
parseTorrentTitle(selectedFile || torrentSource, ({ parsedTitle, originalName }) => {
if (!originalName) return
setSkipDebounce(true)
setTitle('')
setIsCustomTitleEnabled(false)
setOriginalTorrentTitle(originalName)
setParsedTitle(parsedTitle)
})
}, [selectedFile, torrentSource])
useEffect(() => {
if (!selectedFile && !torrentSource) {
setTitle('')
setOriginalTorrentTitle('')
setParsedTitle('')
setIsCustomTitleEnabled(false)
setPosterList()
removePoster()
setIsUserInteractedWithPoster(false)
}
}, [selectedFile, torrentSource])
const removePoster = () => {
setIsPosterUrlCorrect(false)
setPosterUrl('')
}
useEffect(() => {
if (originalHash) {
checkImageURL(posterUrl).then(correctImage => {
correctImage ? setIsPosterUrlCorrect(true) : removePoster()
})
}
// This is needed only on mount. Do not remove line below
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const posterSearch = useMemo(
() =>
(movieName, language, { shouldRefreshMainPoster = false } = {}) => {
if (!movieName) {
setPosterList()
removePoster()
return
}
getMoviePosters(movieName, language).then(urlList => {
if (urlList) {
setPosterList(urlList)
if (!shouldRefreshMainPoster && isUserInteractedWithPoster) return
const [firstPoster] = urlList
checkImageURL(firstPoster).then(correctImage => {
if (correctImage) {
setIsPosterUrlCorrect(true)
setPosterUrl(firstPoster)
} else removePoster()
})
} else {
setPosterList()
if (isUserInteractedWithPoster) return
removePoster()
}
})
},
[isUserInteractedWithPoster],
)
const delayedPosterSearch = useMemo(() => debounce(posterSearch, 700), [posterSearch])
const prevTorrentSourceState = usePreviousState(torrentSource)
useEffect(() => {
const isCorrectSource = chechTorrentSource(torrentSource)
if (!isCorrectSource) return setIsTorrentSourceCorrect(false)
setIsTorrentSourceCorrect(true)
// if torrentSource is updated then we are getting title from the source
const torrentSourceChanged = torrentSource !== prevTorrentSourceState
if (!torrentSourceChanged) return
updateTitleFromSource()
}, [prevTorrentSourceState, selectedFile, torrentSource, updateTitleFromSource])
const prevTitleState = usePreviousState(title)
useEffect(() => {
// if title exists and title was changed then search poster.
const titleChanged = title !== prevTitleState
if (!titleChanged && !parsedTitle) return
if (skipDebounce) {
posterSearch(title || parsedTitle, posterSearchLanguage)
setSkipDebounce(false)
} else if (!title) {
delayedPosterSearch.cancel()
if (parsedTitle) {
posterSearch(parsedTitle, posterSearchLanguage)
} else {
!isUserInteractedWithPoster && removePoster()
}
} else {
delayedPosterSearch(title, posterSearchLanguage)
}
}, [
title,
parsedTitle,
prevTitleState,
delayedPosterSearch,
posterSearch,
posterSearchLanguage,
skipDebounce,
isUserInteractedWithPoster,
])
const handleSave = () => {
axios.post(torrentsHost(), { action: 'add', link, title, poster, save_to_db: true }).finally(() => handleClose())
setIsSaving(true)
if (isEditMode) {
axios
.post(torrentsHost(), {
action: 'set',
hash: originalHash,
title: title || originalName,
poster: posterUrl,
})
.finally(handleClose)
} else if (selectedFile) {
// file save
const data = new FormData()
data.append('save', 'true')
data.append('file', selectedFile)
title && data.append('title', title)
posterUrl && data.append('poster', posterUrl)
axios.post(torrentUploadHost(), data).catch(handleClose)
} else {
// link save
axios
.post(torrentsHost(), { action: 'add', link: torrentSource, title, poster: posterUrl, save_to_db: true })
.catch(handleClose)
}
}
return (
<Dialog open onClose={handleClose} aria-labelledby='form-dialog-title' fullWidth>
<DialogTitle id='form-dialog-title'>{t('AddMagnetOrLink')}</DialogTitle>
<Dialog open onClose={handleClose} fullScreen={fullScreen} fullWidth maxWidth='md'>
<Header>{t(isEditMode ? 'EditTorrent' : 'AddNewTorrent')}</Header>
<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
<Content isEditMode={isEditMode}>
{!isEditMode && (
<LeftSideComponent
setIsUserInteractedWithPoster={setIsUserInteractedWithPoster}
setSelectedFile={setSelectedFile}
torrentSource={torrentSource}
setTorrentSource={setTorrentSource}
selectedFile={selectedFile}
/>
)}
<RightSideComponent
originalTorrentTitle={originalTorrentTitle}
setTitle={setTitle}
setPosterUrl={setPosterUrl}
setIsPosterUrlCorrect={setIsPosterUrlCorrect}
setIsUserInteractedWithPoster={setIsUserInteractedWithPoster}
setPosterList={setPosterList}
isTorrentSourceCorrect={isTorrentSourceCorrect}
isHashAlreadyExists={isHashAlreadyExists}
title={title}
parsedTitle={parsedTitle}
posterUrl={posterUrl}
isPosterUrlCorrect={isPosterUrlCorrect}
posterList={posterList}
currentLang={currentLang}
posterSearchLanguage={posterSearchLanguage}
setPosterSearchLanguage={setPosterSearchLanguage}
posterSearch={posterSearch}
removePoster={removePoster}
updateTitleFromSource={updateTitleFromSource}
torrentSource={torrentSource}
isCustomTitleEnabled={isCustomTitleEnabled}
setIsCustomTitleEnabled={setIsCustomTitleEnabled}
isEditMode={isEditMode}
/>
</DialogContent>
</Content>
<DialogActions>
<Button onClick={handleClose} color='primary' variant='outlined'>
<ButtonWrapper>
<Button onClick={handleClose} color='secondary' variant='outlined'>
{t('Cancel')}
</Button>
<Button variant='contained' disabled={!link} onClick={handleSave} color='primary'>
{t('Add')}
<Button
variant='contained'
style={{ minWidth: '110px' }}
disabled={!torrentSource || (isHashAlreadyExists && !isEditMode) || !isTorrentSourceCorrect}
onClick={handleSave}
color='primary'
>
{isSaving ? <CircularProgress style={{ color: 'white' }} size={20} /> : t(isEditMode ? 'Save' : 'Add')}
</Button>
</DialogActions>
</ButtonWrapper>
</Dialog>
)
}

View File

@@ -0,0 +1,87 @@
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { AddItemIcon, TorrentIcon } from 'icons'
import TextField from '@material-ui/core/TextField'
import { Cancel as CancelIcon } from '@material-ui/icons'
import { useState } from 'react'
import {
CancelIconWrapper,
IconWrapper,
LeftSide,
LeftSideBottomSectionFileSelected,
LeftSideBottomSectionNoFile,
LeftSideTopSection,
TorrentIconWrapper,
} from './style'
export default function LeftSideComponent({
setIsUserInteractedWithPoster,
setSelectedFile,
torrentSource,
setTorrentSource,
selectedFile,
}) {
const { t } = useTranslation()
const handleCapture = files => {
const [file] = files
if (!file) return
setIsUserInteractedWithPoster(false)
setSelectedFile(file)
setTorrentSource(file.name)
}
const clearSelectedFile = () => {
setSelectedFile()
setTorrentSource('')
}
const [isTorrentSourceActive, setIsTorrentSourceActive] = useState(false)
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop: handleCapture, accept: '.torrent' })
const handleTorrentSourceChange = ({ target: { value } }) => setTorrentSource(value)
return (
<LeftSide>
<LeftSideTopSection active={isTorrentSourceActive}>
<TextField
onChange={handleTorrentSourceChange}
value={torrentSource}
margin='dense'
label={t('AddDialog.TorrentSourceLink')}
helperText={t('AddDialog.TorrentSourceOptions')}
type='text'
fullWidth
onFocus={() => setIsTorrentSourceActive(true)}
onBlur={() => setIsTorrentSourceActive(false)}
inputProps={{ autoComplete: 'off' }}
disabled={!!selectedFile}
/>
</LeftSideTopSection>
{selectedFile ? (
<LeftSideBottomSectionFileSelected>
<TorrentIconWrapper>
<TorrentIcon />
<CancelIconWrapper onClick={clearSelectedFile}>
<CancelIcon />
</CancelIconWrapper>
</TorrentIconWrapper>
</LeftSideBottomSectionFileSelected>
) : (
<LeftSideBottomSectionNoFile isDragActive={isDragActive} {...getRootProps()}>
<input {...getInputProps()} />
<div>{t('AddDialog.AppendFile.Or')}</div>
<IconWrapper>
<AddItemIcon color='primary' />
<div>{t('AddDialog.AppendFile.ClickOrDrag')}</div>
</IconWrapper>
</LeftSideBottomSectionNoFile>
)}
</LeftSide>
)
}

View File

@@ -0,0 +1,183 @@
import { useTranslation } from 'react-i18next'
import { NoImageIcon } from 'icons'
import { IconButton, InputAdornment, TextField, useTheme } from '@material-ui/core'
import { CheckBox as CheckBoxIcon } from '@material-ui/icons'
import {
ClearPosterButton,
PosterLanguageSwitch,
RightSide,
Poster,
PosterSuggestions,
PosterSuggestionsItem,
PosterWrapper,
RightSideContainer,
} from './style'
import { checkImageURL } from './helpers'
export default function RightSideComponent({
setTitle,
setPosterUrl,
setIsPosterUrlCorrect,
setIsUserInteractedWithPoster,
setPosterList,
isTorrentSourceCorrect,
isHashAlreadyExists,
title,
parsedTitle,
posterUrl,
isPosterUrlCorrect,
posterList,
currentLang,
posterSearchLanguage,
setPosterSearchLanguage,
posterSearch,
removePoster,
torrentSource,
originalTorrentTitle,
updateTitleFromSource,
isCustomTitleEnabled,
setIsCustomTitleEnabled,
isEditMode,
}) {
const { t } = useTranslation()
const primary = useTheme().palette.primary.main
const handleTitleChange = ({ target: { value } }) => setTitle(value)
const handlePosterUrlChange = ({ target: { value } }) => {
setPosterUrl(value)
checkImageURL(value).then(setIsPosterUrlCorrect)
setIsUserInteractedWithPoster(!!value)
setPosterList()
}
const userChangesPosterUrl = url => {
setPosterUrl(url)
checkImageURL(url).then(setIsPosterUrlCorrect)
setIsUserInteractedWithPoster(true)
}
return (
<RightSide>
<RightSideContainer isHidden={!isTorrentSourceCorrect || (isHashAlreadyExists && !isEditMode)}>
{originalTorrentTitle ? (
<>
<TextField
value={originalTorrentTitle}
margin='dense'
label={t('AddDialog.OriginalTorrentTitle')}
type='text'
fullWidth
disabled={isCustomTitleEnabled}
InputProps={{ readOnly: true }}
/>
<TextField
onChange={handleTitleChange}
onFocus={() => setIsCustomTitleEnabled(true)}
onBlur={({ target: { value } }) => !value && setIsCustomTitleEnabled(false)}
value={title}
margin='dense'
label={t('AddDialog.CustomTorrentTitle')}
type='text'
fullWidth
helperText={t('AddDialog.CustomTorrentTitleHelperText')}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
<IconButton
style={{ padding: '0 0 0 7px' }}
onClick={() => {
setTitle('')
setIsCustomTitleEnabled(!isCustomTitleEnabled)
updateTitleFromSource()
setIsUserInteractedWithPoster(false)
}}
>
<CheckBoxIcon style={{ color: isCustomTitleEnabled ? primary : 'gray' }} />
</IconButton>
</InputAdornment>
),
}}
/>
</>
) : (
<TextField
onChange={handleTitleChange}
value={title}
margin='dense'
label={t('AddDialog.TitleBlank')}
type='text'
fullWidth
/>
)}
<TextField
onChange={handlePosterUrlChange}
value={posterUrl}
margin='dense'
label={t('AddDialog.AddPosterLinkInput')}
type='url'
fullWidth
/>
<PosterWrapper>
<Poster poster={+isPosterUrlCorrect}>
{isPosterUrlCorrect ? <img src={posterUrl} alt='poster' /> : <NoImageIcon />}
</Poster>
<PosterSuggestions>
{posterList
?.filter(url => url !== posterUrl)
.slice(0, 12)
.map(url => (
<PosterSuggestionsItem onClick={() => userChangesPosterUrl(url)} key={url}>
<img src={url} alt='poster' />
</PosterSuggestionsItem>
))}
</PosterSuggestions>
{currentLang !== 'en' && (
<PosterLanguageSwitch
onClick={() => {
const newLanguage = posterSearchLanguage === 'en' ? 'ru' : 'en'
setPosterSearchLanguage(newLanguage)
posterSearch(isCustomTitleEnabled ? title : originalTorrentTitle ? parsedTitle : title, newLanguage, {
shouldRefreshMainPoster: true,
})
}}
showbutton={+isPosterUrlCorrect}
color='primary'
variant='contained'
size='small'
>
{posterSearchLanguage === 'en' ? 'EN' : 'RU'}
</PosterLanguageSwitch>
)}
<ClearPosterButton
showbutton={+isPosterUrlCorrect}
onClick={() => {
removePoster()
setIsUserInteractedWithPoster(true)
}}
color='primary'
variant='contained'
size='small'
>
{t('Clear')}
</ClearPosterButton>
</PosterWrapper>
</RightSideContainer>
<RightSideContainer
isError={torrentSource && (!isTorrentSourceCorrect || isHashAlreadyExists)}
notificationMessage={
!torrentSource
? t('AddDialog.AddTorrentSourceNotification')
: !isTorrentSourceCorrect
? t('AddDialog.WrongTorrentSource')
: isHashAlreadyExists && t('AddDialog.HashExists')
}
isHidden={isEditMode || (isTorrentSourceCorrect && !isHashAlreadyExists)}
/>
</RightSide>
)
}

View File

@@ -0,0 +1,55 @@
import axios from 'axios'
import parseTorrent from 'parse-torrent'
import ptt from 'parse-torrent-title'
export const getMoviePosters = (movieName, language = 'en') => {
const url = 'http://api.themoviedb.org/3/search/multi'
return axios
.get(url, {
params: {
api_key: process.env.REACT_APP_TMDB_API_KEY,
language,
include_image_language: `${language},null,en`,
query: movieName,
},
})
.then(({ data: { results } }) =>
results.filter(el => el.poster_path).map(el => `https://image.tmdb.org/t/p/w300${el.poster_path}`),
)
.catch(() => null)
}
export const checkImageURL = async url => {
if (!url || !url.match(/.(jpg|jpeg|png|gif)$/i)) return false
try {
await fetch(url, { mode: 'no-cors' })
return true
} catch (e) {
return false
}
}
const magnetRegex = /^magnet:\?xt=urn:[a-z0-9].*/i
export const hashRegex = /^\b[0-9a-f]{32}\b$|^\b[0-9a-f]{40}\b$|^\b[0-9a-f]{64}\b$/i
const torrentRegex = /^.*\.(torrent)$/i
export const chechTorrentSource = source =>
source.match(hashRegex) !== null || source.match(magnetRegex) !== null || source.match(torrentRegex) !== null
export const parseTorrentTitle = (parsingSource, callback) => {
parseTorrent.remote(parsingSource, (err, { name, files } = {}) => {
if (!name || err) return callback({ parsedTitle: null, originalName: null })
const torrentName = ptt.parse(name).title
const nameOfFileInsideTorrent = files ? ptt.parse(files[0].name).title : null
let newTitle = torrentName
if (nameOfFileInsideTorrent) {
// taking shorter title because in most cases it is more accurate
newTitle = torrentName.length < nameOfFileInsideTorrent.length ? torrentName : nameOfFileInsideTorrent
}
callback({ parsedTitle: newTitle, originalName: name })
})
}

View File

@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import AddDialog from './AddDialog'
export default function AddDialogButton() {
export default function AddDialogButton({ isOffline, isLoading }) {
const { t } = useTranslation()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const handleClickOpen = () => setIsDialogOpen(true)
@@ -15,7 +15,7 @@ export default function AddDialogButton() {
return (
<div>
<ListItem button key='Add' onClick={handleClickOpen}>
<ListItem disabled={isOffline || isLoading} button onClick={handleClickOpen}>
<ListItemIcon>
<LibraryAddIcon />
</ListItemIcon>

View File

@@ -0,0 +1,338 @@
import { Button } from '@material-ui/core'
import styled, { css } from 'styled-components'
export const Content = styled.div`
${({
isEditMode,
theme: {
addDialog: { gradientStartColor, gradientEndColor, fontColor },
},
}) => css`
height: 550px;
background: linear-gradient(145deg, ${gradientStartColor}, ${gradientEndColor});
flex: 1;
display: grid;
grid-template-columns: repeat(${isEditMode ? '1' : '2'}, 1fr);
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
overflow: auto;
color: ${fontColor};
@media (max-width: 540px) {
${'' /* Just for bug fixing on small screens */}
overflow: scroll;
}
@media (max-width: 930px) {
grid-template-columns: 1fr;
}
@media (max-width: 500px) {
align-content: start;
}
`}
`
export const RightSide = styled.div`
padding: 0 20px 20px 20px;
`
export const RightSideContainer = styled.div`
${({
isHidden,
notificationMessage,
isError,
theme: {
addDialog: { notificationErrorBGColor, notificationSuccessBGColor },
},
}) => css`
height: 530px;
${notificationMessage &&
css`
position: relative;
white-space: nowrap;
:before {
font-size: 20px;
font-weight: 300;
content: '${notificationMessage}';
display: grid;
place-items: center;
background: ${isError ? notificationErrorBGColor : notificationSuccessBGColor};
padding: 10px 15px;
position: absolute;
top: 52%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 5px;
}
`};
${isHidden &&
css`
display: none;
`};
@media (max-width: 500px) {
height: 170px;
}
`}
`
export const LeftSide = styled.div`
display: flex;
flex-direction: column;
border-right: 1px solid rgba(0, 0, 0, 0.12);
`
export const LeftSideBottomSectionBasicStyles = css`
transition: transform 0.3s;
padding: 20px;
height: 100%;
display: grid;
`
export const LeftSideBottomSectionNoFile = styled.div`
${LeftSideBottomSectionBasicStyles}
border: 4px dashed rgba(0,0,0,0.1);
text-align: center;
${({ isDragActive }) => isDragActive && `border: 4px dashed green`};
justify-items: center;
grid-template-rows: 130px 1fr;
cursor: pointer;
:hover {
background-color: rgba(0, 0, 0, 0.04);
svg {
transform: translateY(-4%);
}
}
@media (max-width: 930px) {
border: 4px dashed transparent;
height: 400px;
place-items: center;
grid-template-rows: 40% 1fr;
}
@media (max-width: 500px) {
height: 170px;
grid-template-rows: 1fr;
> div:first-of-type {
display: none;
}
}
`
export const LeftSideBottomSectionFileSelected = styled.div`
${LeftSideBottomSectionBasicStyles}
place-items: center;
@media (max-width: 930px) {
height: 400px;
}
@media (max-width: 500px) {
height: 170px;
}
`
export const TorrentIconWrapper = styled.div`
position: relative;
`
export const CancelIconWrapper = styled.div`
position: absolute;
top: -9px;
left: 10px;
cursor: pointer;
> svg {
transition: all 0.3s;
fill: rgba(0, 0, 0, 0.7);
:hover {
fill: rgba(0, 0, 0, 0.6);
}
}
`
export const IconWrapper = styled.div`
display: grid;
justify-items: center;
align-content: start;
gap: 10px;
align-self: start;
svg {
transition: all 0.3s;
}
`
export const LeftSideTopSection = styled.div`
${({
active,
theme: {
addDialog: { gradientStartColor },
},
}) => css`
background: ${gradientStartColor};
padding: 0 20px 20px 20px;
transition: all 0.3s;
${active && 'box-shadow: 0 8px 10px -9px rgba(0, 0, 0, 0.5)'};
`}
`
export const PosterWrapper = styled.div`
margin-top: 20px;
display: grid;
grid-template-columns: max-content 1fr;
grid-template-rows: 300px max-content;
column-gap: 5px;
position: relative;
margin-bottom: 20px;
grid-template-areas:
'poster suggestions'
'clear empty';
@media (max-width: 540px) {
grid-template-columns: 1fr;
gap: 5px 0;
justify-items: center;
grid-template-areas:
'poster'
'clear'
'suggestions';
}
`
export const PosterSuggestions = styled.div`
display: grid;
grid-area: suggestions;
grid-auto-flow: column;
grid-template-columns: repeat(3, max-content);
grid-template-rows: repeat(4, max-content);
gap: 5px;
@media (max-width: 540px) {
grid-auto-flow: row;
grid-template-columns: repeat(5, max-content);
}
@media (max-width: 375px) {
grid-template-columns: repeat(4, max-content);
}
`
export const PosterSuggestionsItem = styled.div`
cursor: pointer;
width: 71px;
height: 71px;
@media (max-width: 430px) {
width: 60px;
height: 60px;
}
@media (max-width: 375px) {
width: 71px;
height: 71px;
}
@media (max-width: 355px) {
width: 60px;
height: 60px;
}
img {
transition: all 0.3s;
border-radius: 5px;
width: 100%;
height: 100%;
object-fit: cover;
:hover {
filter: brightness(130%);
}
}
`
export const Poster = styled.div`
${({
poster,
theme: {
addDialog: { posterBGColor },
},
}) => css`
border-radius: 5px;
overflow: hidden;
width: 200px;
grid-area: poster;
${poster
? css`
img {
width: 200px;
object-fit: cover;
border-radius: 5px;
height: 100%;
}
`
: css`
display: grid;
place-items: center;
background: ${posterBGColor};
svg {
transform: scale(1.5) translateY(-3px);
}
`}
`}
`
export const ClearPosterButton = styled(Button)`
grid-area: clear;
justify-self: center;
transform: translateY(-50%);
position: absolute;
${({ showbutton }) => !showbutton && 'display: none'};
@media (max-width: 540px) {
transform: translateY(-140%);
}
`
export const PosterLanguageSwitch = styled.div`
${({
showbutton,
theme: {
addDialog: { languageSwitchBGColor, languageSwitchFontColor },
},
}) => css`
grid-area: poster;
z-index: 5;
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
width: 30px;
height: 30px;
background: ${languageSwitchBGColor};
border-radius: 50%;
display: grid;
place-items: center;
color: ${languageSwitchFontColor};
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
${!showbutton && 'display: none'};
:hover {
filter: brightness(1.1);
}
`}
`

View File

@@ -0,0 +1,49 @@
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 { CreditCard as CreditCardIcon } from '@material-ui/icons'
import List from '@material-ui/core/List'
import { useTranslation } from 'react-i18next'
import AddDialogButton from 'components/Add'
import SettingsDialog from 'components/Settings'
import RemoveAll from 'components/RemoveAll'
import AboutDialog from 'components/About'
import CloseServer from 'components/CloseServer'
import { memo } from 'react'
import { AppSidebarStyle } from './style'
const Sidebar = ({ isDrawerOpen, setIsDonationDialogOpen, isOffline, isLoading }) => {
const { t } = useTranslation()
return (
<AppSidebarStyle isDrawerOpen={isDrawerOpen}>
<List>
<AddDialogButton isOffline={isOffline} isLoading={isLoading} />
<RemoveAll isOffline={isOffline} isLoading={isLoading} />
</List>
<Divider />
<List>
<SettingsDialog isOffline={isOffline} isLoading={isLoading} />
<AboutDialog />
<ListItem button onClick={() => setIsDonationDialogOpen(true)}>
<ListItemIcon>
<CreditCardIcon />
</ListItemIcon>
<ListItemText primary={t('Donate')} />
</ListItem>
<CloseServer isOffline={isOffline} isLoading={isLoading} />
</List>
</AppSidebarStyle>
)
}
export default memo(Sidebar)

View File

@@ -0,0 +1,125 @@
import CssBaseline from '@material-ui/core/CssBaseline'
import { createContext, 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,
Brightness4 as Brightness4Icon,
Brightness5 as Brightness5Icon,
BrightnessAuto as BrightnessAutoIcon,
} from '@material-ui/icons'
import { echoHost } from 'utils/Hosts'
import Div100vh from 'react-div-100vh'
import axios from 'axios'
import TorrentList from 'components/TorrentList'
import DonateSnackbar from 'components/Donate'
import DonateDialog from 'components/Donate/DonateDialog'
import useChangeLanguage from 'utils/useChangeLanguage'
import { ThemeProvider as MuiThemeProvider } from '@material-ui/core/styles'
import { ThemeProvider as StyledComponentsThemeProvider } from 'styled-components'
import { useQuery } from 'react-query'
import { getTorrents } from 'utils/Utils'
import GlobalStyle from 'style/GlobalStyle'
import { AppWrapper, AppHeader, HeaderToggle } from './style'
import Sidebar from './Sidebar'
import { lightTheme, THEME_MODES, useMaterialUITheme } from '../../style/materialUISetup'
import getStyledComponentsTheme from '../../style/getStyledComponentsTheme'
export const DarkModeContext = createContext()
export default function App() {
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const [isDonationDialogOpen, setIsDonationDialogOpen] = useState(false)
const [torrServerVersion, setTorrServerVersion] = useState('')
const [isDarkMode, currentThemeMode, updateThemeMode, muiTheme] = useMaterialUITheme()
const [currentLang, changeLang] = useChangeLanguage()
const [isOffline, setIsOffline] = useState(false)
const { data: torrents, isLoading } = useQuery('torrents', getTorrents, {
retry: 1,
refetchInterval: 1000,
onError: () => setIsOffline(true),
onSuccess: () => setIsOffline(false),
})
useEffect(() => {
axios.get(echoHost()).then(({ data }) => setTorrServerVersion(data))
}, [])
return (
<>
<GlobalStyle />
<DarkModeContext.Provider value={{ isDarkMode }}>
<MuiThemeProvider theme={muiTheme}>
<StyledComponentsThemeProvider
theme={getStyledComponentsTheme(isDarkMode ? THEME_MODES.DARK : THEME_MODES.LIGHT)}
>
<CssBaseline />
{/* Div100vh - iOS WebKit fix */}
<Div100vh>
<AppWrapper>
<AppHeader>
<IconButton
edge='start'
color='inherit'
onClick={() => setIsDrawerOpen(!isDrawerOpen)}
style={{ marginRight: '6px' }}
>
{isDrawerOpen ? <CloseIcon /> : <MenuIcon />}
</IconButton>
<Typography variant='h6' noWrap>
TorrServer {torrServerVersion}
</Typography>
<div
style={{ justifySelf: 'end', display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '10px' }}
>
<HeaderToggle
onClick={() => {
if (currentThemeMode === THEME_MODES.LIGHT) updateThemeMode(THEME_MODES.DARK)
if (currentThemeMode === THEME_MODES.DARK) updateThemeMode(THEME_MODES.AUTO)
if (currentThemeMode === THEME_MODES.AUTO) updateThemeMode(THEME_MODES.LIGHT)
}}
>
{currentThemeMode === THEME_MODES.LIGHT ? (
<Brightness5Icon />
) : currentThemeMode === THEME_MODES.DARK ? (
<Brightness4Icon />
) : (
<BrightnessAutoIcon />
)}
</HeaderToggle>
<HeaderToggle onClick={() => (currentLang === 'en' ? changeLang('ru') : changeLang('en'))}>
{currentLang === 'en' ? 'EN' : 'RU'}
</HeaderToggle>
</div>
</AppHeader>
<Sidebar
isOffline={isOffline}
isLoading={isLoading}
isDrawerOpen={isDrawerOpen}
setIsDonationDialogOpen={setIsDonationDialogOpen}
/>
<TorrentList isOffline={isOffline} torrents={torrents} isLoading={isLoading} />
<MuiThemeProvider theme={lightTheme}>
{isDonationDialogOpen && <DonateDialog onClose={() => setIsDonationDialogOpen(false)} />}
</MuiThemeProvider>
{!JSON.parse(localStorage.getItem('snackbarIsClosed')) && <DonateSnackbar />}
</AppWrapper>
</Div100vh>
</StyledComponentsThemeProvider>
</MuiThemeProvider>
</DarkModeContext.Provider>
</>
)
}

View File

@@ -0,0 +1,119 @@
import { rgba } from 'polished'
import styled, { css } from 'styled-components'
export const AppWrapper = styled.div`
${({
theme: {
app: { appSecondaryColor },
},
}) => css`
height: 100%;
background: ${rgba(appSecondaryColor, 0.8)};
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`
${({ theme: { primary } }) => css`
background: ${primary};
color: #fff;
grid-area: head;
display: grid;
grid-auto-flow: column;
align-items: center;
grid-template-columns: repeat(2, max-content) 1fr;
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 16px;
z-index: 3;
`}
`
export const AppSidebarStyle = styled.div`
${({
isDrawerOpen,
theme: {
app: { appSecondaryColor, sidebarBGColor, sidebarFillColor },
},
}) => 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(appSecondaryColor, 0.12)};
background: ${sidebarBGColor};
color: ${sidebarFillColor};
white-space: nowrap;
svg {
fill: ${sidebarFillColor};
}
`}
`
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;
}
`
export const HeaderToggle = styled.div`
${({
theme: {
app: { headerToggleColor },
},
}) => css`
cursor: pointer;
border-radius: 50%;
background: ${headerToggleColor};
height: 35px;
width: 35px;
transition: all 0.2s;
font-weight: 600;
display: grid;
place-items: center;
color: #fff;
:hover {
background: ${rgba(headerToggleColor, 0.7)};
}
@media (max-width: 700px) {
height: 28px;
width: 28px;
font-size: 12px;
svg {
width: 17px;
}
}
`}
`

View File

@@ -4,7 +4,7 @@ import { PowerSettingsNew as PowerSettingsNewIcon } from '@material-ui/icons'
import { shutdownHost } from 'utils/Hosts'
import { useTranslation } from 'react-i18next'
export default function CloseServer() {
export default function CloseServer({ isOffline, isLoading }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const closeDialog = () => setOpen(false)
@@ -12,7 +12,7 @@ export default function CloseServer() {
return (
<>
<ListItem button key={t('CloseServer')} onClick={openDialog}>
<ListItem disabled={isOffline || isLoading} button key={t('CloseServer')} onClick={openDialog}>
<ListItemIcon>
<PowerSettingsNewIcon />
</ListItemIcon>
@@ -23,7 +23,7 @@ export default function CloseServer() {
<Dialog open={open} onClose={closeDialog}>
<DialogTitle>{t('CloseServer?')}</DialogTitle>
<DialogActions>
<Button variant='outlined' onClick={closeDialog} color='primary'>
<Button variant='outlined' onClick={closeDialog} color='secondary'>
{t('Cancel')}
</Button>
@@ -33,7 +33,7 @@ export default function CloseServer() {
fetch(shutdownHost())
closeDialog()
}}
color='primary'
color='secondary'
autoFocus
>
{t('TurnOff')}

View File

@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next'
import { SectionTitle, WidgetWrapper } from '../style'
import { DetailedViewCacheSection, DetailedViewWidgetSection } from './style'
import TorrentCache from '../TorrentCache'
@@ -11,20 +13,23 @@ import {
DownlodSpeedWidget,
} from '../widgets'
export default function Test({
export default function DetailedView({
downloadSpeed,
uploadSpeed,
torrent,
torrentSize,
PiecesCount,
PiecesLength,
statString,
stat,
cache,
}) {
const { t } = useTranslation()
return (
<>
<DetailedViewWidgetSection>
<SectionTitle mb={20}>Data</SectionTitle>
<SectionTitle mb={20}>{t('Data')}</SectionTitle>
<WidgetWrapper detailedView>
<DownlodSpeedWidget data={downloadSpeed} />
<UploadSpeedWidget data={uploadSpeed} />
@@ -32,12 +37,14 @@ export default function Test({
<SizeWidget data={torrentSize} />
<PiecesCountWidget data={PiecesCount} />
<PiecesLengthWidget data={PiecesLength} />
<StatusWidget data={statString} />
<StatusWidget stat={stat} />
</WidgetWrapper>
</DetailedViewWidgetSection>
<DetailedViewCacheSection>
<SectionTitle mb={20}>Cache</SectionTitle>
<SectionTitle color='#000' mb={20}>
{t('Cache')}
</SectionTitle>
<TorrentCache cache={cache} />
</DetailedViewCacheSection>
</>

View File

@@ -1,19 +1,33 @@
import styled from 'styled-components'
import styled, { css } from 'styled-components'
export const DetailedViewWidgetSection = styled.section`
padding: 40px;
background: linear-gradient(145deg, #e4f6ed, #b5dec9);
${({
theme: {
detailedView: { gradientStartColor, gradientEndColor },
},
}) => css`
padding: 40px;
background: linear-gradient(145deg, ${gradientStartColor}, ${gradientEndColor});
@media (max-width: 800px) {
padding: 20px;
}
@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);
${({
theme: {
detailedView: { cacheSectionBGColor },
},
}) => css`
padding: 40px;
box-shadow: inset 3px 25px 8px -25px rgba(0, 0, 0, 0.5);
background: ${cacheSectionBGColor};
flex: 1;
@media (max-width: 800px) {
padding: 20px;
}
@media (max-width: 800px) {
padding: 20px;
}
`}
`

View File

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
const useStyles = makeStyles(theme => ({
appBar: { position: 'relative' },
title: { marginLeft: theme.spacing(2), flex: 1 },
title: { marginLeft: '6px', flex: 1 },
}))
export default function DialogHeader({ title, onClose, onBack }) {

View File

@@ -10,6 +10,11 @@ import { TableStyle, ShortTableWrapper, ShortTable } from './style'
const { memo } = require('react')
// russian episode detection support
ptt.addHandler('episode', /(\d{1,4})[- |. ]серия|серия[- |. ](\d{1,4})/i, { type: 'integer' })
ptt.addHandler('season', /sezon[- |. ](\d{1,3})|(\d{1,3})[- |. ]sezon/i, { type: 'integer' })
ptt.addHandler('season', /сезон[- |. ](\d{1,3})|(\d{1,3})[- |. ]сезон/i, { type: 'integer' })
const Table = memo(
({ playableFileList, viewedFileList, selectedSeason, seasonAmount, hash }) => {
const { t } = useTranslation()
@@ -20,6 +25,9 @@ const Table = memo(
const fileHasSeasonText = !!playableFileList?.find(({ path }) => ptt.parse(path).season)
const fileHasResolutionText = !!playableFileList?.find(({ path }) => ptt.parse(path).resolution)
// if files in list is more then 1 and no season text detected by ptt.parse, show full name
const shouldDisplayFullFileName = playableFileList.length > 1 && !fileHasEpisodeText
return !playableFileList?.length ? (
'No playable files in this torrent'
) : (
@@ -47,27 +55,29 @@ const Table = memo(
(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>
<td data-label='name'>{shouldDisplayFullFileName ? path : 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')}
<td>
<div className='button-cell'>
<Button onClick={() => preloadBuffer(id)} variant='outlined' color='primary' size='small'>
{t('Preload')}
</Button>
</a>
<CopyToClipboard text={link}>
<Button variant='outlined' color='primary' size='small'>
{t('CopyLink')}
</Button>
</CopyToClipboard>
<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>
</td>
</tr>
)
@@ -85,7 +95,7 @@ const Table = memo(
return (
(season === selectedSeason || !seasonAmount?.length) && (
<ShortTable key={id} isViewed={isViewed}>
<div className='short-table-name'>{title}</div>
<div className='short-table-name'>{shouldDisplayFullFileName ? path : title}</div>
<div className='short-table-data'>
{isViewed && (
<div className='short-table-field'>

View File

@@ -1,68 +1,89 @@
import styled, { css } from 'styled-components'
const viewedPrimaryColor = '#bdbdbd'
const viewedSecondaryColor = '#c4c4c4'
const viewedTertiaryColor = '#c9c9c9'
const bigTableDividerColor = '#ddd'
const bigTableDefaultRowColor = '#fff'
const bigTableViewedRowColor = '#f3f3f3'
const viewedIndicator = css`
:before {
content: '';
width: 10px;
height: 10px;
background: #15d5af;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
${({
theme: {
table: { defaultPrimaryColor },
},
}) => css`
:before {
content: '';
width: 10px;
height: 10px;
background: ${defaultPrimaryColor};
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);
${({
theme: {
table: { defaultPrimaryColor },
},
}) => css`
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);
color: #000;
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;
thead tr {
background: ${defaultPrimaryColor};
color: #fff;
text-align: left;
text-transform: uppercase;
}
&.viewed-file-row {
background: #f3f3f3;
}
}
td {
&.viewed-file-indicator {
position: relative;
${viewedIndicator}
th,
td {
padding: 12px 15px;
}
&.button-cell {
tbody tr {
border-bottom: 1px solid ${bigTableDividerColor};
background: ${bigTableDefaultRowColor};
:last-of-type {
border-bottom: 2px solid ${defaultPrimaryColor};
}
&.viewed-file-row {
background: ${bigTableViewedRowColor};
}
}
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;
}
@media (max-width: 970px) {
display: none;
}
`}
`
export const ShortTableWrapper = styled.div`
@@ -82,7 +103,12 @@ export const ShortTableWrapper = styled.div`
`
export const ShortTable = styled.div`
${({ isViewed }) => css`
${({
isViewed,
theme: {
table: { defaultPrimaryColor, defaultSecondaryColor, defaultTertiaryColor },
},
}) => css`
width: 100%;
grid-template-rows: repeat(3, max-content);
border-radius: 5px;
@@ -91,7 +117,7 @@ export const ShortTable = styled.div`
.short-table {
&-name {
background: ${isViewed ? '#bdbdbd' : '#009879'};
background: ${isViewed ? viewedPrimaryColor : defaultPrimaryColor};
display: grid;
place-items: center;
padding: 15px;
@@ -116,11 +142,11 @@ export const ShortTable = styled.div`
grid-template-rows: 30px 1fr;
background: black;
:not(:last-child) {
border-right: 1px solid ${isViewed ? '#bdbdbd' : '#019376'};
border-right: 1px solid ${isViewed ? viewedPrimaryColor : defaultPrimaryColor};
}
&-name {
background: ${isViewed ? '#c4c4c4' : '#00a383'};
background: ${isViewed ? viewedSecondaryColor : defaultSecondaryColor};
color: #fff;
text-transform: uppercase;
font-size: 12px;
@@ -135,7 +161,7 @@ export const ShortTable = styled.div`
}
&-value {
background: ${isViewed ? '#c9c9c9' : '#03aa89'};
background: ${isViewed ? viewedTertiaryColor : defaultTertiaryColor};
display: grid;
place-items: center;
color: #fff;
@@ -156,11 +182,12 @@ export const ShortTable = styled.div`
&-buttons {
padding: 20px;
border-bottom: 2px solid ${isViewed ? '#bdbdbd' : '#009879'};
border-bottom: 2px solid ${isViewed ? viewedPrimaryColor : defaultPrimaryColor};
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
gap: 20px;
background: #fff;
@media (max-width: 410px) {
gap: 10px;

View File

@@ -1,5 +1,5 @@
export default ({ cacheMap, preloadPiecesAmount, piecesInOneRow }) => {
const cacheMapWithoutEmptyBlocks = cacheMap.filter(({ isComplete, inProgress }) => inProgress || isComplete)
const cacheMapWithoutEmptyBlocks = cacheMap.filter(({ percentage }) => percentage > 0)
const getFullAmountOfBlocks = amountOfBlocks =>
// this function counts existed amount of blocks with extra "empty blocks" to fill the row till the end

View File

@@ -1,26 +1,131 @@
import { memo } from 'react'
import Measure from 'react-measure'
import { useState, memo, useRef, useEffect, useContext } from 'react'
import { useTranslation } from 'react-i18next'
import isEqual from 'lodash/isEqual'
import { DarkModeContext } from 'components/App'
import { THEME_MODES } from 'style/materialUISetup'
import { useCreateCacheMap } from '../customHooks'
import LargeSnake from './LargeSnake'
import DefaultSnake from './DefaultSnake'
import getShortCacheMap from './getShortCacheMap'
import { SnakeWrapper, ScrollNotification } from './style'
import { createGradient, snakeSettings } from './snakeSettings'
const TorrentCache = memo(
({ cache, isMini }) => {
const cacheMap = useCreateCacheMap(cache)
const TorrentCache = ({ cache, isMini }) => {
const { t } = useTranslation()
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
const { width } = dimensions
const canvasRef = useRef(null)
const ctxRef = useRef(null)
const cacheMap = useCreateCacheMap(cache)
const settingsTarget = isMini ? 'mini' : 'default'
const { isDarkMode } = useContext(DarkModeContext)
const theme = isDarkMode ? THEME_MODES.DARK : THEME_MODES.LIGHT
const {
readerColor,
rangeColor,
borderWidth,
pieceSize,
gapBetweenPieces,
backgroundColor,
borderColor,
cacheMaxHeight,
completeColor,
} = snakeSettings[theme][settingsTarget]
const canvasWidth = isMini ? width * 0.93 : width
const pieceSizeWithGap = pieceSize + gapBetweenPieces
const piecesInOneRow = Math.floor(canvasWidth / pieceSizeWithGap)
let shotCacheMap
if (isMini) {
const preloadPiecesAmount = Math.round(cache.Capacity / cache.PiecesLength - 1)
const isSnakeLarge = cacheMap.length > 5000
shotCacheMap = getShortCacheMap({ cacheMap, preloadPiecesAmount, piecesInOneRow })
}
const source = isMini ? shotCacheMap : cacheMap
const startingXPoint = Math.ceil((canvasWidth - pieceSizeWithGap * piecesInOneRow) / 2) // needed to center grid
const height = Math.ceil(source.length / piecesInOneRow) * pieceSizeWithGap
return isMini ? (
<DefaultSnake isMini cacheMap={cacheMap} preloadPiecesAmount={preloadPiecesAmount} />
) : isSnakeLarge ? (
<LargeSnake cacheMap={cacheMap} />
) : (
<DefaultSnake cacheMap={cacheMap} preloadPiecesAmount={preloadPiecesAmount} />
)
},
useEffect(() => {
if (!canvasWidth || !height) return
const canvas = canvasRef.current
canvas.width = canvasWidth
canvas.height = height
ctxRef.current = canvas.getContext('2d')
}, [canvasRef, height, canvasWidth])
useEffect(() => {
const ctx = ctxRef.current
if (!ctx) return
ctx.clearRect(0, 0, canvasWidth, height)
source.forEach(({ percentage, isReader, isReaderRange }, i) => {
const inProgress = percentage > 0 && percentage < 100
const isCompleted = percentage === 100
const currentRow = i % piecesInOneRow
const currentColumn = Math.floor(i / piecesInOneRow)
const fixBlurStroke = borderWidth % 2 === 0 ? 0 : 0.5
const requiredFix = Math.ceil(borderWidth / 2) + 1 + fixBlurStroke
const x = currentRow * pieceSize + currentRow * gapBetweenPieces + startingXPoint + requiredFix
const y = currentColumn * pieceSize + currentColumn * gapBetweenPieces + requiredFix
ctx.lineWidth = borderWidth
ctx.fillStyle = inProgress
? createGradient(ctx, percentage, theme, settingsTarget)
: isCompleted
? completeColor
: backgroundColor
ctx.strokeStyle = isReader
? readerColor
: inProgress || isCompleted
? completeColor
: isReaderRange
? rangeColor
: borderColor
ctx.translate(x, y)
ctx.fillRect(0, 0, pieceSize, pieceSize)
ctx.strokeRect(0, 0, pieceSize, pieceSize)
ctx.setTransform(1, 0, 0, 1, 0, 0)
})
}, [
cacheMap,
height,
canvasWidth,
piecesInOneRow,
startingXPoint,
pieceSize,
gapBetweenPieces,
source,
backgroundColor,
borderColor,
borderWidth,
settingsTarget,
completeColor,
readerColor,
rangeColor,
theme,
])
return (
<Measure bounds onResize={({ bounds }) => setDimensions(bounds)}>
{({ measureRef }) => (
<div style={{ display: 'flex', flexDirection: 'column' }} ref={measureRef}>
<SnakeWrapper themeType={theme} isMini={isMini}>
<canvas ref={canvasRef} />
</SnakeWrapper>
{isMini && height >= cacheMaxHeight && <ScrollNotification>{t('ScrollDown')}</ScrollNotification>}
</div>
)}
</Measure>
)
}
export default memo(
TorrentCache,
(prev, next) => isEqual(prev.cache.Pieces, next.cache.Pieces) && isEqual(prev.cache.Readers, next.cache.Readers),
)
export default TorrentCache

View File

@@ -0,0 +1,67 @@
import { rgba } from 'polished'
import { mainColors } from 'style/colors'
export const snakeSettings = {
dark: {
default: {
borderWidth: 2,
pieceSize: 14,
gapBetweenPieces: 3,
borderColor: mainColors.dark.secondary,
completeColor: rgba(mainColors.dark.primary, 0.65),
backgroundColor: '#f1eff3',
progressColor: mainColors.dark.secondary,
readerColor: '#000',
rangeColor: '#cda184',
},
mini: {
cacheMaxHeight: 340,
borderWidth: 2,
pieceSize: 23,
gapBetweenPieces: 6,
borderColor: '#545a5e',
completeColor: '#545a5e',
backgroundColor: '#dee3e5',
progressColor: '#dee3e5',
readerColor: '#000',
rangeColor: '#cda184',
},
},
light: {
default: {
borderWidth: 1,
pieceSize: 14,
gapBetweenPieces: 3,
borderColor: '#dbf2e8',
completeColor: mainColors.light.primary,
backgroundColor: '#fff',
progressColor: '#b3dfc9',
readerColor: '#000',
rangeColor: '#afa6e3',
},
mini: {
cacheMaxHeight: 340,
borderWidth: 2,
pieceSize: 23,
gapBetweenPieces: 6,
borderColor: '#4db380',
completeColor: '#4db380',
backgroundColor: '#dbf2e8',
progressColor: '#dbf2e8',
readerColor: '#2d714f',
rangeColor: '#afa6e3',
},
},
}
export const createGradient = (ctx, percentage, theme, snakeType) => {
const { pieceSize, completeColor, progressColor } = snakeSettings[theme][snakeType]
const gradient = ctx.createLinearGradient(0, pieceSize, 0, 0)
gradient.addColorStop(0, completeColor)
gradient.addColorStop(percentage / 100, completeColor)
gradient.addColorStop(percentage / 100, progressColor)
gradient.addColorStop(1, progressColor)
return gradient
}

View File

@@ -0,0 +1,26 @@
import styled, { css } from 'styled-components'
import { snakeSettings } from './snakeSettings'
export const ScrollNotification = styled.div`
margin-top: 10px;
text-transform: uppercase;
color: rgba(0, 0, 0, 0.5);
align-self: center;
`
export const SnakeWrapper = styled.div`
${({ isMini, themeType }) => css`
${isMini &&
css`
display: grid;
justify-content: center;
max-height: ${snakeSettings[themeType].mini.cacheMaxHeight}px;
overflow: auto;
`}
canvas {
display: block;
}
`}
`

View File

@@ -19,11 +19,17 @@ export const MainSectionButtonGroup = styled.div`
`
export const SmallLabel = styled.div`
${({ mb }) => css`
${({
mb,
theme: {
torrentFunctions: { fontColor },
},
}) => css`
${mb && `margin-bottom: ${mb}px`};
font-size: 20px;
font-weight: 300;
line-height: 1;
color: ${fontColor};
@media (max-width: 800px) {
font-size: 18px;

View File

@@ -38,32 +38,22 @@ export const useCreateCacheMap = cache => {
const [cacheMap, setCacheMap] = useState([])
useEffect(() => {
if (!cache.PiecesCount || !cache.Pieces) return
const { Pieces, PiecesCount, Readers } = cache
const { PiecesCount, Pieces, Readers } = cache
const map = []
for (let i = 0; i < PiecesCount; i++) {
const newPiece = { id: i }
const { Size, Length } = Pieces[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)
}
}
const newPiece = { id: i, percentage: (Size / Length) * 100 || 0 }
Readers.forEach(r => {
if (i === r.Reader) newPiece.isActive = true
if (i === r.Reader) newPiece.isReader = true
if (i >= r.Start && i <= r.End) newPiece.isReaderRange = true
})
map.push(newPiece)
}
setCacheMap(map)
}, [cache])

View File

@@ -1,5 +1,5 @@
import { NoImageIcon } from 'icons'
import { humanizeSize, shortenText } from 'utils/Utils'
import { humanizeSize, removeRedundantCharacters } from 'utils/Utils'
import { useEffect, useState } from 'react'
import { Button, ButtonGroup } from '@material-ui/core'
import ptt from 'parse-torrent-title'
@@ -33,7 +33,7 @@ import { isFilePlayable } from './helpers'
const Loader = () => (
<div style={{ minHeight: '80vh', display: 'grid', placeItems: 'center' }}>
<CircularProgress />
<CircularProgress color='secondary' />
</div>
)
@@ -54,7 +54,6 @@ export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
stat,
download_speed: downloadSpeed,
upload_speed: uploadSpeed,
stat_string: statString,
torrent_size: torrentSize,
file_stats: torrentFileList,
} = torrent
@@ -100,29 +99,49 @@ export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
})
}, [hash])
const bufferSize = settings?.PreloadBuffer ? Capacity : 33554432 // Default is 32mb if PreloadBuffer is false
// const bufferSize = Capacity
const preloadPerc = settings?.PreloadCache
const preloadSize = (Capacity / 100) * preloadPerc
const bufferSize = preloadSize > 33554432 ? preloadSize : 33554432 // Not less than 32MB
const getTitle = value => {
const torrentParsedName = value && ptt.parse(value)
const newNameStrings = []
const getParsedTitle = () => {
const newNameStringArr = []
if (torrentParsedName?.title) newNameStrings.push(` ${torrentParsedName?.title}`)
if (torrentParsedName?.year) newNameStrings.push(`. ${torrentParsedName?.year}.`)
if (torrentParsedName?.resolution) newNameStrings.push(` (${torrentParsedName?.resolution})`)
const torrentParsedName = name && ptt.parse(name)
return newNameStrings.join(' ')
if (title !== name) {
newNameStringArr.push(removeRedundantCharacters(title))
} else if (torrentParsedName?.title) newNameStringArr.push(removeRedundantCharacters(torrentParsedName?.title))
// These 2 checks are needed to get year and resolution from torrent name if title does not have this info
if (torrentParsedName?.year && !newNameStringArr[0].includes(torrentParsedName?.year))
newNameStringArr.push(torrentParsedName?.year)
if (torrentParsedName?.resolution && !newNameStringArr[0].includes(torrentParsedName?.resolution))
newNameStringArr.push(torrentParsedName?.resolution)
const newNameString = newNameStringArr.join('. ')
// removeRedundantCharacters is returning ".." if it was "..."
const lastDotShouldBeAdded =
newNameString[newNameString.length - 1] === '.' && newNameString[newNameString.length - 2] === '.'
return lastDotShouldBeAdded ? `${newNameString}.` : newNameString
}
return (
<>
<DialogHeader
onClose={closeDialog}
title={isDetailedCacheView ? t('DetailedCacheView') : t('TorrentDetails')}
title={isDetailedCacheView ? t('DetailedCacheView.header') : t('TorrentDetails')}
{...(isDetailedCacheView && { onBack: () => setIsDetailedCacheView(false) })}
/>
<div style={{ minHeight: '80vh', overflow: 'auto' }}>
<div
style={{
minHeight: '80vh',
overflow: 'auto',
...(isDetailedCacheView && { display: 'flex', flexDirection: 'column' }),
}}
>
{isLoading ? (
<Loader />
) : isDetailedCacheView ? (
@@ -133,7 +152,7 @@ export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
torrentSize={torrentSize}
PiecesCount={PiecesCount}
PiecesLength={PiecesLength}
statString={statString}
stat={stat}
cache={cache}
/>
) : (
@@ -142,13 +161,20 @@ export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
<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>
</>
{title && name !== title ? (
getParsedTitle().length > 90 ? (
<>
<SectionTitle>{ptt.parse(name).title}</SectionTitle>
<SectionSubName mb={20}>{getParsedTitle()}</SectionSubName>
</>
) : (
<>
<SectionTitle>{getParsedTitle()}</SectionTitle>
<SectionSubName mb={20}>{ptt.parse(name || '')?.title}</SectionSubName>
</>
)
) : (
<SectionTitle mb={20}>{shortenText(getTitle(title), 50)}</SectionTitle>
<SectionTitle mb={20}>{getParsedTitle()}</SectionTitle>
)}
<WidgetWrapper>
@@ -156,7 +182,7 @@ export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
<UploadSpeedWidget data={uploadSpeed} />
<PeersWidget data={torrent} />
<SizeWidget data={torrentSize} />
<StatusWidget data={statString} />
<StatusWidget stat={stat} />
</WidgetWrapper>
<Divider />
@@ -175,11 +201,11 @@ export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
<CacheSection>
<SectionHeader>
<SectionTitle mb={20}>{t('Buffer')}</SectionTitle>
{!settings?.PreloadBuffer && <SectionSubName>{t('BufferNote')}</SectionSubName>}
{bufferSize <= 33554432 && <SectionSubName>{t('BufferNote')}</SectionSubName>}
<LoadingProgress
value={Filled}
fullAmount={bufferSize}
label={`${humanizeSize(Filled) || '0 B'} / ${humanizeSize(bufferSize)}`}
label={`${humanizeSize(bufferSize)} / ${humanizeSize(Filled) || `0 ${t('B')}`}`}
/>
</SectionHeader>
@@ -191,7 +217,7 @@ export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
size='large'
onClick={() => setIsDetailedCacheView(true)}
>
{t('DetailedCacheView')}
{t('DetailedCacheView.button')}
</Button>
</CacheSection>
@@ -201,7 +227,7 @@ export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
{seasonAmount?.length > 1 && (
<>
<SectionSubName mb={7}>{t('SelectSeason')}</SectionSubName>
<ButtonGroup style={{ marginBottom: '30px' }} color='primary'>
<ButtonGroup style={{ marginBottom: '30px' }} color='secondary'>
{seasonAmount.map(season => (
<Button
key={season}

View File

@@ -1,3 +1,4 @@
import { rgba } from 'polished'
import styled, { css } from 'styled-components'
export const DialogContentGrid = styled.div`
@@ -18,7 +19,12 @@ export const DialogContentGrid = styled.div`
}
`
export const Poster = styled.div`
${({ poster }) => css`
${({
poster,
theme: {
dialogTorrentDetailsContent: { posterBGColor },
},
}) => css`
height: 400px;
border-radius: 5px;
overflow: hidden;
@@ -35,7 +41,7 @@ export const Poster = styled.div`
width: 300px;
display: grid;
place-items: center;
background: #74c39c;
background: ${posterBGColor};
svg {
transform: scale(2.5) translateY(-3px);
@@ -58,72 +64,105 @@ export const Poster = styled.div`
`}
`
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);
${({
theme: {
dialogTorrentDetailsContent: { gradientStartColor, gradientEndColor },
},
}) => css`
grid-area: main;
padding: 40px;
display: grid;
grid-template-columns: min-content 1fr;
gap: 30px;
background: linear-gradient(145deg, ${gradientStartColor}, ${gradientEndColor});
@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: 840px) {
grid-template-columns: 1fr;
}
@media (max-width: 800px) {
${mb && `margin-bottom: ${mb / 2}px`};
font-size: 11px;
padding: 20px;
}
`}
`
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;
export const CacheSection = styled.section`
${({
theme: {
dialogTorrentDetailsContent: { chacheSectionBGColor },
},
}) => css`
grid-area: cache;
padding: 40px;
display: grid;
align-content: start;
grid-template-rows: min-content 1fr min-content;
background: ${chacheSectionBGColor};
@media (max-width: 800px) {
font-size: 25px;
${mb && `margin-bottom: ${mb / 2}px`};
padding: 20px;
}
`}
`
export const TorrentFilesSection = styled.section`
${({
theme: {
dialogTorrentDetailsContent: { torrentFilesSectionBGColor },
},
}) => css`
grid-area: file-list;
padding: 40px;
box-shadow: inset 3px 25px 8px -25px rgba(0, 0, 0, 0.5);
background: ${torrentFilesSectionBGColor};
@media (max-width: 800px) {
padding: 20px;
}
`}
`
export const SectionSubName = styled.div`
${({
theme: {
dialogTorrentDetailsContent: { subNameFontColor },
},
}) => css`
${({ mb }) => css`
${mb && `margin-bottom: ${mb}px`};
color: ${subNameFontColor};
@media (max-width: 800px) {
${mb && `margin-bottom: ${mb / 2}px`};
font-size: 11px;
}
`}
`}
`
export const SectionTitle = styled.div`
${({
color,
theme: {
dialogTorrentDetailsContent: { titleFontColor },
},
}) => css`
${({ mb }) => css`
${mb && `margin-bottom: ${mb}px`};
font-size: 35px;
font-weight: 300;
line-height: 1;
word-break: break-word;
color: ${color || titleFontColor};
@media (max-width: 800px) {
font-size: 25px;
${mb && `margin-bottom: ${mb / 2}px`};
}
`}
`}
`
export const SectionHeader = styled.div`
margin-bottom: 20px;
`
@@ -182,18 +221,25 @@ export const WidgetFieldWrapper = styled.div`
}
`
export const WidgetFieldTitle = styled.div`
grid-area: title;
justify-self: start;
text-transform: uppercase;
font-size: 11px;
margin-bottom: 2px;
font-weight: 500;
${({
theme: {
dialogTorrentDetailsContent: { titleFontColor },
},
}) => css`
grid-area: title;
justify-self: start;
text-transform: uppercase;
font-size: 11px;
margin-bottom: 2px;
font-weight: 600;
color: ${titleFontColor};
`}
`
export const WidgetFieldIcon = styled.div`
${({ bgColor }) => css`
grid-area: icon;
color: rgba(255, 255, 255, 0.8);
color: ${rgba('#fff', 0.8)};
background: ${bgColor};
border-radius: 5px 0 0 5px;
@@ -205,10 +251,15 @@ export const WidgetFieldIcon = styled.div`
`}
`
export const WidgetFieldValue = styled.div`
${({ bgColor }) => css`
${({
bgColor,
theme: {
dialogTorrentDetailsContent: { widgetFontColor },
},
}) => css`
grid-area: value;
padding: 0 20px;
color: #fff;
color: ${widgetFontColor};
font-size: 25px;
background: ${bgColor};
border-radius: 0 5px 5px 0;
@@ -220,20 +271,29 @@ export const WidgetFieldValue = styled.div`
`}
`
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%)`,
export const LoadingProgress = styled.div.attrs(
({
value,
fullAmount,
theme: {
dialogTorrentDetailsContent: { gradientEndColor },
},
}
})`
}) => {
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, ${gradientEndColor} 0%, ${gradientEndColor} ${percentage}%, #fff ${percentage}%, #fff 100%)`,
},
}
},
)`
${({ label }) => css`
border: 1px solid;
padding: 10px 20px;
border-radius: 5px;
color: #000;
:before {
content: '${label}';

View File

@@ -0,0 +1,128 @@
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, humanizeSpeed } from 'utils/Utils'
import { useTranslation } from 'react-i18next'
import { GETTING_INFO, IN_DB, CLOSED, PRELOAD, WORKING } from 'torrentStates'
import StatisticsField from '../StatisticsField'
import useGetWidgetColors from './useGetWidgetColors'
export const DownlodSpeedWidget = ({ data }) => {
const { t } = useTranslation()
const { iconBGColor, valueBGColor } = useGetWidgetColors('downloadSpeed')
return (
<StatisticsField
title={t('DownloadSpeed')}
value={humanizeSpeed(data) || `0 ${t('bps')}`}
iconBg={iconBGColor}
valueBg={valueBGColor}
icon={ArrowDownwardIcon}
/>
)
}
export const UploadSpeedWidget = ({ data }) => {
const { t } = useTranslation()
const { iconBGColor, valueBGColor } = useGetWidgetColors('uploadSpeed')
return (
<StatisticsField
title={t('UploadSpeed')}
value={humanizeSpeed(data) || `0 ${t('bps')}`}
iconBg={iconBGColor}
valueBg={valueBGColor}
icon={ArrowUpwardIcon}
/>
)
}
export const PeersWidget = ({ data }) => {
const { t } = useTranslation()
const { iconBGColor, valueBGColor } = useGetWidgetColors('peers')
return (
<StatisticsField
title={t('Peers')}
value={getPeerString(data) || '0 · 0 / 0'}
iconBg={iconBGColor}
valueBg={valueBGColor}
icon={SwapVerticalCircleIcon}
/>
)
}
export const PiecesCountWidget = ({ data }) => {
const { t } = useTranslation()
const { iconBGColor, valueBGColor } = useGetWidgetColors('piecesCount')
return (
<StatisticsField
title={t('PiecesCount')}
value={data}
iconBg={iconBGColor}
valueBg={valueBGColor}
icon={WidgetsIcon}
/>
)
}
export const PiecesLengthWidget = ({ data }) => {
const { t } = useTranslation()
const { iconBGColor, valueBGColor } = useGetWidgetColors('piecesLength')
return (
<StatisticsField
title={t('PiecesLength')}
value={humanizeSize(data)}
iconBg={iconBGColor}
valueBg={valueBGColor}
icon={PhotoSizeSelectSmallIcon}
/>
)
}
export const StatusWidget = ({ stat }) => {
const { t } = useTranslation()
const values = {
[GETTING_INFO]: t('TorrentGettingInfo'),
[PRELOAD]: t('TorrentPreload'),
[WORKING]: t('TorrentWorking'),
[CLOSED]: t('TorrentClosed'),
[IN_DB]: t('TorrentInDb'),
}
const { iconBGColor, valueBGColor } = useGetWidgetColors('status')
return (
<StatisticsField
title={t('TorrentStatus')}
value={values[stat]}
iconBg={iconBGColor}
valueBg={valueBGColor}
icon={BuildIcon}
/>
)
}
export const SizeWidget = ({ data }) => {
const { t } = useTranslation()
const { iconBGColor, valueBGColor } = useGetWidgetColors('size')
return (
<StatisticsField
title={t('TorrentSize')}
value={humanizeSize(data)}
iconBg={iconBGColor}
valueBg={valueBGColor}
icon={ViewAgendaIcon}
/>
)
}

View File

@@ -0,0 +1,33 @@
import { DarkModeContext } from 'components/App'
import { useContext } from 'react'
import { THEME_MODES } from 'style/materialUISetup'
const { LIGHT, DARK } = THEME_MODES
const colors = {
light: {
downloadSpeed: { iconBGColor: '#118f00', valueBGColor: '#13a300' },
uploadSpeed: { iconBGColor: '#0146ad', valueBGColor: '#0058db' },
peers: { iconBGColor: '#cdc118', valueBGColor: '#d8cb18' },
piecesCount: { iconBGColor: '#b6c95e', valueBGColor: '#c0d076' },
piecesLength: { iconBGColor: '#0982c8', valueBGColor: '#098cd7' },
status: { iconBGColor: '#aea25b', valueBGColor: '#b4aa6e' },
size: { iconBGColor: '#9b01ad', valueBGColor: '#ac03bf' },
},
dark: {
downloadSpeed: { iconBGColor: '#0c6600', valueBGColor: '#0d7000' },
uploadSpeed: { iconBGColor: '#003f9e', valueBGColor: '#0047b3' },
peers: { iconBGColor: '#a69c11', valueBGColor: '#b4a913' },
piecesCount: { iconBGColor: '#8da136', valueBGColor: '#99ae3d' },
piecesLength: { iconBGColor: '#07659c', valueBGColor: '#0872af' },
status: { iconBGColor: '#938948', valueBGColor: '#9f9450' },
size: { iconBGColor: '#81008f', valueBGColor: '#9102a1' },
},
}
export default function useGetWidgetColors(widgetName) {
const { isDarkMode } = useContext(DarkModeContext)
const widgetColors = colors[isDarkMode ? DARK : LIGHT][widgetName]
return widgetColors
}

View File

@@ -15,7 +15,7 @@ export default function DonateDialog({ onClose }) {
const { t } = useTranslation()
return (
<Dialog open onClose={onClose} aria-labelledby='form-dialog-title' fullWidth>
<Dialog open onClose={onClose} aria-labelledby='form-dialog-title' fullWidth maxWidth='xs'>
<DialogTitle id='form-dialog-title'>{t('Donate')}</DialogTitle>
<DialogContent>
<List>
@@ -33,7 +33,7 @@ export default function DonateDialog({ onClose }) {
</DialogContent>
<DialogActions>
<Button onClick={onClose} color='primary' variant='outlined'>
<Button onClick={onClose} color='secondary' variant='contained'>
Ok
</Button>
</DialogActions>

View File

@@ -31,14 +31,15 @@ const fnRemoveAll = () => {
})
}
export default function RemoveAll() {
export default function RemoveAll({ isOffline, isLoading }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const closeDialog = () => setOpen(false)
const openDialog = () => setOpen(true)
return (
<>
<ListItem button key={t('RemoveAll')} onClick={openDialog}>
<ListItem disabled={isOffline || isLoading} button key={t('RemoveAll')} onClick={openDialog}>
<ListItemIcon>
<DeleteIcon />
</ListItemIcon>
@@ -49,7 +50,7 @@ export default function RemoveAll() {
<Dialog open={open} onClose={closeDialog}>
<DialogTitle>{t('DeleteTorrents?')}</DialogTitle>
<DialogActions>
<Button variant='outlined' onClick={closeDialog} color='primary'>
<Button variant='outlined' onClick={closeDialog} color='secondary'>
{t('Cancel')}
</Button>
@@ -59,7 +60,7 @@ export default function RemoveAll() {
fnRemoveAll()
closeDialog()
}}
color='primary'
color='secondary'
autoFocus
>
{t('OK')}

View File

@@ -0,0 +1,178 @@
import { useTranslation } from 'react-i18next'
import { USBIcon, RAMIcon } from 'icons'
import { FormControlLabel, Switch } from '@material-ui/core'
import TextField from '@material-ui/core/TextField'
import {
PreloadCacheValue,
MainSettingsContent,
StorageButton,
StorageIconWrapper,
CacheStorageSelector,
SettingSectionLabel,
PreloadCachePercentage,
cacheBeforeReaderColor,
cacheAfterReaderColor,
} from './style'
import SliderInput from './SliderInput'
const CacheStorageLocationLabel = ({ style }) => {
const { t } = useTranslation()
return (
<SettingSectionLabel style={style}>
{t('SettingsDialog.CacheStorageLocation')}
<small>{t('SettingsDialog.UseDiskDesc')}</small>
</SettingSectionLabel>
)
}
export default function PrimarySettingsComponent({
settings,
inputForm,
cachePercentage,
preloadCachePercentage,
cacheSize,
isProMode,
setCacheSize,
setCachePercentage,
setPreloadCachePercentage,
updateSettings,
}) {
const { t } = useTranslation()
const { UseDisk, TorrentsSavePath, RemoveCacheOnDrop } = settings || {}
const preloadCacheSize = Math.round((cacheSize / 100) * preloadCachePercentage)
return (
<MainSettingsContent>
<div>
<SettingSectionLabel>{t('SettingsDialog.CacheSettings')}</SettingSectionLabel>
<PreloadCachePercentage
value={100 - cachePercentage}
label={`${t('Cache')} ${cacheSize} MB`}
preloadCachePercentage={preloadCachePercentage}
/>
<PreloadCacheValue color={cacheBeforeReaderColor}>
<div>
{100 - cachePercentage}% ({Math.round((cacheSize / 100) * (100 - cachePercentage))} MB)
</div>
<div>{t('SettingsDialog.CacheBeforeReaderDesc')}</div>
</PreloadCacheValue>
<PreloadCacheValue color={cacheAfterReaderColor}>
<div>
{cachePercentage}% ({Math.round((cacheSize / 100) * cachePercentage)} MB)
</div>
<div>{t('SettingsDialog.CacheAfterReaderDesc')}</div>
</PreloadCacheValue>
<br />
<SliderInput
isProMode={isProMode}
title={t('SettingsDialog.CacheSize')}
value={cacheSize}
setValue={setCacheSize}
sliderMin={32}
sliderMax={1024}
inputMin={32}
inputMax={20000}
step={8}
onBlurCallback={value => setCacheSize(Math.round(value / 8) * 8)}
/>
<SliderInput
isProMode={isProMode}
title={t('SettingsDialog.ReaderReadAHead')}
value={cachePercentage}
setValue={setCachePercentage}
sliderMin={40}
sliderMax={95}
inputMin={0}
inputMax={100}
/>
<SliderInput
isProMode={isProMode}
title={`${t('SettingsDialog.PreloadCache')} - ${preloadCachePercentage}% (${preloadCacheSize} MB)`}
value={preloadCachePercentage}
setValue={setPreloadCachePercentage}
sliderMin={0}
sliderMax={100}
inputMin={0}
inputMax={100}
/>
</div>
{UseDisk ? (
<div>
<CacheStorageLocationLabel />
<div style={{ display: 'grid', gridAutoFlow: 'column' }}>
<StorageButton small onClick={() => updateSettings({ UseDisk: false })}>
<StorageIconWrapper small>
<RAMIcon color='#323637' />
</StorageIconWrapper>
<div>{t('SettingsDialog.RAM')}</div>
</StorageButton>
<StorageButton small selected>
<StorageIconWrapper small selected>
<USBIcon color='#dee3e5' />
</StorageIconWrapper>
<div>{t('SettingsDialog.Disk')}</div>
</StorageButton>
</div>
<FormControlLabel
control={
<Switch checked={RemoveCacheOnDrop} onChange={inputForm} id='RemoveCacheOnDrop' color='secondary' />
}
label={t('SettingsDialog.RemoveCacheOnDrop')}
labelPlacement='start'
/>
<div>
<small>{t('SettingsDialog.RemoveCacheOnDropDesc')}</small>
</div>
<br />
<TextField
onChange={inputForm}
margin='normal'
id='TorrentsSavePath'
label={t('SettingsDialog.TorrentsSavePath')}
value={TorrentsSavePath}
type='url'
variant='outlined'
fullWidth
/>
</div>
) : (
<CacheStorageSelector>
<CacheStorageLocationLabel style={{ placeSelf: 'start', gridArea: 'label' }} />
<StorageButton selected>
<StorageIconWrapper selected>
<RAMIcon color='#dee3e5' />
</StorageIconWrapper>
<div>{t('SettingsDialog.RAM')}</div>
</StorageButton>
<StorageButton onClick={() => updateSettings({ UseDisk: true })}>
<StorageIconWrapper>
<USBIcon color='#323637' />
</StorageIconWrapper>
<div>{t('SettingsDialog.Disk')}</div>
</StorageButton>
</CacheStorageSelector>
)}
</MainSettingsContent>
)
}

View File

@@ -0,0 +1,166 @@
import { useTranslation } from 'react-i18next'
import TextField from '@material-ui/core/TextField'
import { FormControlLabel, InputAdornment, InputLabel, Select, Switch } from '@material-ui/core'
import { SecondarySettingsContent, SettingSectionLabel } from './style'
export default function SecondarySettingsComponent({ settings, inputForm }) {
const { t } = useTranslation()
const {
RetrackersMode,
TorrentDisconnectTimeout,
EnableIPv6,
ForceEncrypt,
DisableTCP,
DisableUTP,
DisableUPNP,
DisableDHT,
DisablePEX,
DisableUpload,
DownloadRateLimit,
UploadRateLimit,
ConnectionsLimit,
DhtConnectionLimit,
PeersListenPort,
} = settings || {}
return (
<SecondarySettingsContent>
<SettingSectionLabel>{t('SettingsDialog.AdditionalSettings')}</SettingSectionLabel>
<FormControlLabel
control={<Switch checked={EnableIPv6} onChange={inputForm} id='EnableIPv6' color='secondary' />}
label='IPv6'
labelPlacement='start'
/>
<FormControlLabel
control={<Switch checked={!DisableTCP} onChange={inputForm} id='DisableTCP' color='secondary' />}
label='TCP (Transmission Control Protocol)'
labelPlacement='start'
/>
<FormControlLabel
control={<Switch checked={!DisableUTP} onChange={inputForm} id='DisableUTP' color='secondary' />}
label='μTP (Micro Transport Protocol)'
labelPlacement='start'
/>
<FormControlLabel
control={<Switch checked={!DisablePEX} onChange={inputForm} id='DisablePEX' color='secondary' />}
label='PEX (Peer Exchange)'
labelPlacement='start'
/>
<FormControlLabel
control={<Switch checked={ForceEncrypt} onChange={inputForm} id='ForceEncrypt' color='secondary' />}
label={t('SettingsDialog.ForceEncrypt')}
labelPlacement='start'
/>
<TextField
onChange={inputForm}
margin='normal'
id='TorrentDisconnectTimeout'
label={t('SettingsDialog.TorrentDisconnectTimeout')}
InputProps={{
endAdornment: <InputAdornment position='end'>{t('Seconds')}</InputAdornment>,
}}
value={TorrentDisconnectTimeout}
type='number'
variant='outlined'
fullWidth
/>
<br />
<TextField
onChange={inputForm}
margin='normal'
id='ConnectionsLimit'
label={t('SettingsDialog.ConnectionsLimit')}
helperText={t('SettingsDialog.ConnectionsLimitHint')}
value={ConnectionsLimit}
type='number'
variant='outlined'
fullWidth
/>
<br />
<FormControlLabel
control={<Switch checked={!DisableDHT} onChange={inputForm} id='DisableDHT' color='secondary' />}
label={t('SettingsDialog.DHT')}
labelPlacement='start'
/>
<TextField
onChange={inputForm}
margin='normal'
id='DhtConnectionLimit'
label={t('SettingsDialog.DhtConnectionLimit')}
value={DhtConnectionLimit}
type='number'
variant='outlined'
fullWidth
/>
<br />
<TextField
onChange={inputForm}
margin='normal'
id='DownloadRateLimit'
label={t('SettingsDialog.DownloadRateLimit')}
InputProps={{
endAdornment: <InputAdornment position='end'>{t('Kilobytes')}</InputAdornment>,
}}
value={DownloadRateLimit}
type='number'
variant='outlined'
fullWidth
/>
<br />
<FormControlLabel
control={<Switch checked={!DisableUpload} onChange={inputForm} id='DisableUpload' color='secondary' />}
label={t('SettingsDialog.Upload')}
labelPlacement='start'
/>
<TextField
onChange={inputForm}
margin='normal'
id='UploadRateLimit'
label={t('SettingsDialog.UploadRateLimit')}
InputProps={{
endAdornment: <InputAdornment position='end'>{t('Kilobytes')}</InputAdornment>,
}}
value={UploadRateLimit}
type='number'
variant='outlined'
fullWidth
/>
<br />
<TextField
onChange={inputForm}
margin='normal'
id='PeersListenPort'
label={t('SettingsDialog.PeersListenPort')}
value={PeersListenPort}
type='number'
variant='outlined'
fullWidth
/>
<FormControlLabel
control={<Switch checked={!DisableUPNP} onChange={inputForm} id='DisableUPNP' color='secondary' />}
label='UPnP (Universal Plug and Play)'
labelPlacement='start'
/>
<br />
<InputLabel htmlFor='RetrackersMode'>{t('SettingsDialog.RetrackersMode')}</InputLabel>
<Select
onChange={inputForm}
margin='normal'
type='number'
native
id='RetrackersMode'
value={RetrackersMode}
variant='outlined'
>
<option value={0}>{t('SettingsDialog.DontAddRetrackers')}</option>
<option value={1}>{t('SettingsDialog.AddRetrackers')}</option>
<option value={2}>{t('SettingsDialog.RemoveRetrackers')}</option>
<option value={3}>{t('SettingsDialog.ReplaceRetrackers')}</option>
</Select>
<br />
</SecondarySettingsContent>
)
}

View File

@@ -0,0 +1,184 @@
import axios from 'axios'
import Dialog from '@material-ui/core/Dialog'
import Button from '@material-ui/core/Button'
import Checkbox from '@material-ui/core/Checkbox'
import { FormControlLabel, useMediaQuery, useTheme } from '@material-ui/core'
import { settingsHost } from 'utils/Hosts'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppBar from '@material-ui/core/AppBar'
import Tabs from '@material-ui/core/Tabs'
import Tab from '@material-ui/core/Tab'
import SwipeableViews from 'react-swipeable-views'
import CircularProgress from '@material-ui/core/CircularProgress'
import { SettingsHeader, FooterSection, Content } from './style'
import defaultSettings from './defaultSettings'
import { a11yProps, TabPanel } from './tabComponents'
import PrimarySettingsComponent from './PrimarySettingsComponent'
import SecondarySettingsComponent from './SecondarySettingsComponent'
export default function SettingsDialog({ handleClose }) {
const { t } = useTranslation()
const fullScreen = useMediaQuery('@media (max-width:930px)')
const { direction } = useTheme()
const [settings, setSettings] = useState()
const [selectedTab, setSelectedTab] = useState(0)
const [cacheSize, setCacheSize] = useState(32)
const [cachePercentage, setCachePercentage] = useState(40)
const [preloadCachePercentage, setPreloadCachePercentage] = useState(0)
const [isProMode, setIsProMode] = useState(JSON.parse(localStorage.getItem('isProMode')) || false)
useEffect(() => {
axios.post(settingsHost(), { action: 'get' }).then(({ data }) => {
setSettings({ ...data, CacheSize: data.CacheSize / (1024 * 1024) })
})
}, [])
const handleSave = () => {
handleClose()
const sets = JSON.parse(JSON.stringify(settings))
sets.CacheSize = cacheSize * 1024 * 1024
sets.ReaderReadAHead = cachePercentage
sets.PreloadCache = preloadCachePercentage
axios.post(settingsHost(), { action: 'set', sets })
}
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
}
setSettings(sets)
}
const { CacheSize, ReaderReadAHead, PreloadCache } = settings || {}
useEffect(() => {
if (isNaN(CacheSize) || isNaN(ReaderReadAHead) || isNaN(PreloadCache)) return
setCacheSize(CacheSize)
setCachePercentage(ReaderReadAHead)
setPreloadCachePercentage(PreloadCache)
}, [CacheSize, ReaderReadAHead, PreloadCache])
const updateSettings = newProps => setSettings({ ...settings, ...newProps })
const handleChange = (_, newValue) => setSelectedTab(newValue)
const handleChangeIndex = index => setSelectedTab(index)
return (
<Dialog open onClose={handleClose} fullScreen={fullScreen} fullWidth maxWidth='md'>
<SettingsHeader>
<div>{t('SettingsDialog.Settings')}</div>
<FormControlLabel
control={
<Checkbox
checked={isProMode}
onChange={({ target: { checked } }) => {
setIsProMode(checked)
localStorage.setItem('isProMode', checked)
if (!checked) setSelectedTab(0)
}}
style={{ color: 'white' }}
/>
}
label={t('SettingsDialog.ProMode')}
/>
</SettingsHeader>
<AppBar position='static' color='default'>
<Tabs
value={selectedTab}
onChange={handleChange}
indicatorColor='secondary'
textColor='secondary'
variant='fullWidth'
>
<Tab label={t('SettingsDialog.Tabs.Main')} {...a11yProps(0)} />
<Tab
disabled={!isProMode}
label={
<>
<div>{t('SettingsDialog.Tabs.Additional')}</div>
{!isProMode && <div style={{ fontSize: '9px' }}>{t('SettingsDialog.Tabs.AdditionalDisabled')}</div>}
</>
}
{...a11yProps(1)}
/>
</Tabs>
</AppBar>
<Content isLoading={!settings}>
{settings ? (
<>
<SwipeableViews
axis={direction === 'rtl' ? 'x-reverse' : 'x'}
index={selectedTab}
onChangeIndex={handleChangeIndex}
>
<TabPanel value={selectedTab} index={0} dir={direction}>
<PrimarySettingsComponent
settings={settings}
inputForm={inputForm}
cachePercentage={cachePercentage}
preloadCachePercentage={preloadCachePercentage}
cacheSize={cacheSize}
isProMode={isProMode}
setCacheSize={setCacheSize}
setCachePercentage={setCachePercentage}
setPreloadCachePercentage={setPreloadCachePercentage}
updateSettings={updateSettings}
/>
</TabPanel>
<TabPanel value={selectedTab} index={1} dir={direction}>
<SecondarySettingsComponent settings={settings} inputForm={inputForm} />
</TabPanel>
</SwipeableViews>
</>
) : (
<CircularProgress color='secondary' />
)}
</Content>
<FooterSection>
<Button onClick={handleClose} color='secondary' variant='outlined'>
{t('Cancel')}
</Button>
<Button
onClick={() => {
setCacheSize(defaultSettings.CacheSize)
setCachePercentage(defaultSettings.ReaderReadAHead)
setPreloadCachePercentage(defaultSettings.PreloadCache)
updateSettings(defaultSettings)
}}
color='secondary'
variant='outlined'
>
{t('SettingsDialog.ResetToDefault')}
</Button>
<Button variant='contained' onClick={handleSave} color='secondary'>
{t('Save')}
</Button>
</FooterSection>
</Dialog>
)
}

View File

@@ -0,0 +1,56 @@
import { Grid, OutlinedInput, Slider } from '@material-ui/core'
export default function SliderInput({
isProMode,
title,
value,
setValue,
sliderMin,
sliderMax,
inputMin,
inputMax,
step = 1,
onBlurCallback,
}) {
const onBlur = ({ target: { value } }) => {
if (value < inputMin) return setValue(inputMin)
if (value > inputMax) return setValue(inputMax)
onBlurCallback && onBlurCallback(value)
}
const onInputChange = ({ target: { value } }) => setValue(value === '' ? '' : Number(value))
const onSliderChange = (_, newValue) => setValue(newValue)
return (
<>
<div>{title}</div>
<Grid container spacing={2} alignItems='center'>
<Grid item xs>
<Slider
min={sliderMin}
max={sliderMax}
value={value}
onChange={onSliderChange}
step={step}
color='secondary'
/>
</Grid>
{isProMode && (
<Grid item>
<OutlinedInput
value={value}
margin='dense'
onChange={onInputChange}
onBlur={onBlur}
style={{ width: '91px', marginTop: '-6px' }}
inputProps={{ step, min: inputMin, max: inputMax, type: 'number' }}
/>
</Grid>
)}
</Grid>
</>
)
}

View File

@@ -0,0 +1,25 @@
export default {
CacheSize: 96,
ReaderReadAHead: 95,
UseDisk: false,
UploadRateLimit: 0,
TorrentsSavePath: '',
ConnectionsLimit: 23,
DhtConnectionLimit: 500,
DisableDHT: false,
DisablePEX: false,
DisableTCP: false,
DisableUPNP: false,
DisableUTP: true,
DisableUpload: false,
DownloadRateLimit: 0,
EnableDebug: false,
EnableIPv6: false,
ForceEncrypt: false,
PeersListenPort: 0,
PreloadCache: 0,
RemoveCacheOnDrop: false,
RetrackersMode: 1,
Strategy: 0,
TorrentDisconnectTimeout: 30,
}

View File

@@ -0,0 +1,29 @@
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import { useState } from 'react'
import SettingsIcon from '@material-ui/icons/Settings'
import { useTranslation } from 'react-i18next'
import SettingsDialog from './SettingsDialog'
export default function SettingsDialogButton({ isOffline, isLoading }) {
const { t } = useTranslation()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const handleClickOpen = () => setIsDialogOpen(true)
const handleClose = () => setIsDialogOpen(false)
return (
<div>
<ListItem disabled={isOffline || isLoading} button onClick={handleClickOpen}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary={t('SettingsDialog.Settings')} />
</ListItem>
{isDialogOpen && <SettingsDialog handleClose={handleClose} />}
</div>
)
}

View File

@@ -0,0 +1,206 @@
import styled, { css } from 'styled-components'
import { mainColors } from 'style/colors'
import { Header } from 'style/DialogStyles'
export const cacheBeforeReaderColor = '#b3dfc9'
export const cacheAfterReaderColor = mainColors.light.primary
export const SettingsHeader = styled(Header)`
display: grid;
grid-auto-flow: column;
align-items: center;
justify-content: space-between;
@media (max-width: 340px) {
grid-auto-flow: row;
}
`
export const FooterSection = styled.div`
${({
theme: {
settingsDialog: { footerBG },
},
}) => css`
padding: 20px;
display: grid;
grid-auto-flow: column;
justify-content: end;
gap: 10px;
align-items: center;
background: ${footerBG};
@media (max-width: 500px) {
grid-auto-flow: row;
justify-content: stretch;
}
`}
`
export const Divider = styled.div`
height: 1px;
background-color: rgba(0, 0, 0, 0.12);
margin: 30px 0;
`
export const Content = styled.div`
${({
isLoading,
theme: {
settingsDialog: { contentBG },
},
}) => css`
background: ${contentBG};
overflow: auto;
flex: 1;
${isLoading &&
css`
min-height: 500px;
display: grid;
place-items: center;
`}
`}
`
export const PreloadCacheValue = styled.div`
${({ color }) => css`
display: grid;
grid-template-columns: max-content 100px 1fr;
gap: 10px;
align-items: flex-start;
:not(:last-child) {
margin-bottom: 5px;
}
:before {
content: '';
background: ${color};
width: 16px;
height: 16px;
border-radius: 50%;
margin-top: 2px;
}
`}
`
export const MainSettingsContent = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 40px;
padding: 20px;
@media (max-width: 930px) {
grid-template-columns: 1fr;
}
`
export const SecondarySettingsContent = styled.div`
padding: 20px;
`
export const StorageButton = styled.div`
${({ small, selected }) => css`
transition: 0.2s;
cursor: default;
text-align: center;
${!selected &&
css`
cursor: pointer;
:hover {
filter: brightness(0.8);
}
`}
${small
? css`
display: grid;
grid-template-columns: max-content 1fr;
gap: 20px;
align-items: center;
justify-items: start;
margin-bottom: 20px;
`
: css`
display: grid;
place-items: center;
gap: 10px;
`}
`}
`
export const StorageIconWrapper = styled.div`
${({ selected, small }) => css`
width: ${small ? '60px' : '150px'};
height: ${small ? '60px' : '150px'};
border-radius: 50%;
background: ${selected ? '#323637' : '#dee3e5'};
svg {
transform: rotate(-45deg) scale(0.75);
}
@media (max-width: 930px) {
width: ${small ? '50px' : '90px'};
height: ${small ? '50px' : '90px'};
}
`}
`
export const CacheStorageSelector = styled.div`
display: grid;
grid-template-rows: max-content 1fr;
grid-template-areas: 'label label';
place-items: center;
@media (max-width: 930px) {
justify-content: start;
column-gap: 30px;
}
`
export const SettingSectionLabel = styled.div`
font-size: 25px;
padding-bottom: 20px;
small {
display: block;
font-size: 11px;
}
`
export const PreloadCachePercentage = styled.div.attrs(({ value }) => ({
// this block is here according to styled-components recomendation about fast changable components
style: {
background: `linear-gradient(to right, ${cacheBeforeReaderColor} 0%, ${cacheBeforeReaderColor} ${value}%, ${cacheAfterReaderColor} ${value}%, ${cacheAfterReaderColor} 100%)`,
},
}))`
${({ label, preloadCachePercentage }) => css`
border: 1px solid #323637;
padding: 10px 20px;
border-radius: 5px;
color: #000;
margin-bottom: 10px;
position: relative;
:before {
content: '${label}';
display: grid;
place-items: center;
font-size: 20px;
}
:after {
content: '';
width: ${preloadCachePercentage}%;
height: 100%;
background: #323637;
position: absolute;
bottom: 0;
left: 0;
border-radius: 4px;
filter: opacity(0.15);
}
`}
`

View File

@@ -0,0 +1,10 @@
export const a11yProps = index => ({
id: `full-width-tab-${index}`,
'aria-controls': `full-width-tabpanel-${index}`,
})
export const TabPanel = ({ children, value, index, ...other }) => (
<div role='tabpanel' hidden={value !== index} id={`full-width-tabpanel-${index}`} {...other}>
{value === index && <>{children}</>}
</div>
)

View File

@@ -1,7 +1,11 @@
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 { forwardRef, memo, useState } from 'react'
import {
UnfoldMore as UnfoldMoreIcon,
Edit as EditIcon,
Close as CloseIcon,
Delete as DeleteIcon,
} from '@material-ui/icons'
import { getPeerString, humanizeSize, humanizeSpeed, removeRedundantCharacters } from 'utils/Utils'
import { torrentsHost } from 'utils/Hosts'
import { NoImageIcon } from 'icons'
import DialogTorrentDetailsContent from 'components/DialogTorrentDetailsContent'
@@ -11,12 +15,13 @@ import { Button, DialogActions, DialogTitle, useMediaQuery, useTheme } from '@ma
import axios from 'axios'
import ptt from 'parse-torrent-title'
import { useTranslation } from 'react-i18next'
import AddDialog from 'components/Add/AddDialog'
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 Torrent = ({ torrent }) => {
const { t } = useTranslation()
const [isDetailedInfoOpened, setIsDetailedInfoOpened] = useState(false)
const [isDeleteTorrentOpened, setIsDeleteTorrentOpened] = useState(false)
@@ -34,7 +39,25 @@ export default function Torrent({ 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
const getParsedTitle = () => {
const parse = key => ptt.parse(title || '')?.[key] || ptt.parse(name || '')?.[key]
const titleStrings = []
let parsedTitle = removeRedundantCharacters(parse('title'))
const parsedYear = parse('year')
const parsedResolution = parse('resolution')
if (parsedTitle) titleStrings.push(parsedTitle)
if (parsedYear) titleStrings.push(`(${parsedYear})`)
if (parsedResolution) titleStrings.push(`[${parsedResolution}]`)
parsedTitle = titleStrings.join(' ')
return { parsedTitle }
}
const { parsedTitle } = getParsedTitle()
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const handleClickOpenEditDialog = () => setIsEditDialogOpen(true)
const handleCloseEditDialog = () => setIsEditDialogOpen(false)
return (
<>
@@ -49,6 +72,11 @@ export default function Torrent({ torrent }) {
<span>{t('Details')}</span>
</StyledButton>
<StyledButton onClick={handleClickOpenEditDialog}>
<EditIcon />
<span>{t('Edit')}</span>
</StyledButton>
<StyledButton onClick={() => dropTorrent(torrent)}>
<CloseIcon />
<span>{t('Drop')}</span>
@@ -63,7 +91,7 @@ export default function Torrent({ torrent }) {
<TorrentCardDescription>
<div className='description-title-wrapper'>
<div className='description-section-name'>{t('Name')}</div>
<div className='description-torrent-title'>{shortenText(parsedTitle, 100)}</div>
<div className='description-torrent-title'>{parsedTitle}</div>
</div>
<div className='description-statistics-wrapper'>
@@ -75,7 +103,7 @@ export default function Torrent({ torrent }) {
<div className='description-statistics-element-wrapper'>
<div className='description-section-name'>{t('Speed')}</div>
<div className='description-statistics-element-value'>
{downloadSpeed > 0 ? humanizeSize(downloadSpeed) : '---'}
{downloadSpeed > 0 ? humanizeSpeed(downloadSpeed) : '---'}
</div>
</div>
@@ -118,6 +146,12 @@ export default function Torrent({ torrent }) {
</Button>
</DialogActions>
</Dialog>
{isEditDialogOpen && (
<AddDialog hash={hash} title={title} name={name} poster={poster} handleClose={handleCloseEditDialog} />
)}
</>
)
}
export default memo(Torrent)

View File

@@ -1,29 +1,35 @@
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%);
${({
theme: {
torrentCard: { cardPrimaryColor },
},
}) => css`
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: ${cardPrimaryColor};
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';
@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;
}
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;
}
@media (max-width: 770px) {
grid-template-columns: 60px 1fr;
grid-template-rows: 90px max-content;
}
`}
`
export const TorrentCardPoster = styled.div`
@@ -32,7 +38,12 @@ export const TorrentCardPoster = styled.div`
overflow: hidden;
text-align: center;
${({ isPoster }) =>
${({
isPoster,
theme: {
torrentCard: { cardSecondaryColor, accentCardColor },
},
}) =>
isPoster
? css`
img {
@@ -45,8 +56,8 @@ export const TorrentCardPoster = styled.div`
: css`
display: grid;
place-items: center;
background: #74c39c;
border: 1px solid #337a57;
background: ${cardSecondaryColor};
border: 1px solid ${accentCardColor};
svg {
transform: translateY(-3px);
@@ -66,141 +77,148 @@ export const TorrentCardButtons = styled.div`
gap: 10px;
@media (max-width: 1260px), (max-height: 500px) {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 340px) {
gap: 5px;
}
`
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 {
${({
theme: {
torrentCard: { cardSecondaryColor, accentCardColor },
},
}) => css`
grid-area: description;
background: ${cardSecondaryColor};
border-radius: 5px;
padding: 5px;
display: grid;
grid-template-columns: 80px 80px 1fr;
align-self: end;
@media (max-width: 1260px), (max-height: 500px) {
grid-template-columns: 70px 70px 1fr;
}
grid-template-rows: 55% 1fr;
gap: 10px;
@media (max-width: 770px) {
grid-template-columns: 65px 65px 1fr;
grid-template-rows: 60% 1fr;
gap: 3px;
}
@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;
.description-title-wrapper {
display: flex;
flex-direction: column;
}
@media (max-width: 410px) {
.description-section-name {
text-transform: uppercase;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.4px;
color: ${accentCardColor};
@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;
${({
theme: {
torrentCard: { buttonBGColor, accentCardColor },
},
}) => css`
border-radius: 5px;
border: none;
cursor: pointer;
transition: 0.2s;
display: flex;
align-items: center;
text-transform: uppercase;
background: ${buttonBGColor};
color: #fff;
font-size: 0.9rem;
letter-spacing: 0.009em;
padding: 0 12px;
svg {
width: 20px;
}
}
@media (max-width: 770px) {
font-size: 0.7rem;
svg {
width: 15px;
:hover {
background: ${accentCardColor};
}
}
@media (max-width: 420px) {
padding: 7px 10px;
justify-content: center;
svg {
display: none;
> :first-child {
margin-right: 10px;
}
}
@media (max-width: 1260px), (max-height: 500px) {
padding: 7px 10px;
justify-content: center;
font-size: 0.8rem;
svg {
display: none;
}
}
@media (max-width: 770px) {
font-size: 0.7rem;
}
@media (max-width: 420px) {
font-size: 0.6rem;
padding: 7px 5px;
}
`}
`

View File

@@ -0,0 +1,31 @@
import { useTheme } from '@material-ui/core'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AddDialog from '../Add/AddDialog'
import IconWrapper from './style'
export default function AddFirstTorrent() {
const { t } = useTranslation()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const handleClickOpen = () => setIsDialogOpen(true)
const handleClose = () => setIsDialogOpen(false)
const primary = useTheme().palette.primary.main
return (
<>
<IconWrapper onClick={() => handleClickOpen(true)} isButton>
<lord-icon
src='https://cdn.lordicon.com/bbnkwdur.json'
trigger='loop'
colors={`primary:#121331,secondary:${primary}`}
stroke='26'
scale='60'
/>
<div className='icon-label'>{t('NoTorrentsAdded')}</div>
</IconWrapper>
{isDialogOpen && <AddDialog handleClose={handleClose} />}
</>
)
}

View File

@@ -0,0 +1,22 @@
import { useTheme } from '@material-ui/core'
import { useTranslation } from 'react-i18next'
import IconWrapper from './style'
export default function NoServerConnection() {
const { t } = useTranslation()
const primary = useTheme().palette.primary.main
return (
<IconWrapper>
<lord-icon
src='https://cdn.lordicon.com/wrprwmwt.json'
trigger='loop'
colors={`primary:#121331,secondary:${primary}`}
stroke='26'
scale='60'
/>
<div className='icon-label'>{t('Offline')}</div>
</IconWrapper>
)
}

View File

@@ -0,0 +1,30 @@
import TorrentCard from 'components/TorrentCard'
import CircularProgress from '@material-ui/core/CircularProgress'
import { TorrentListWrapper, CenteredGrid } from 'components/App/style'
import NoServerConnection from './NoServerConnection'
import AddFirstTorrent from './AddFirstTorrent'
export default function TorrentList({ isOffline, isLoading, torrents }) {
if (isLoading || isOffline || !torrents.length) {
return (
<CenteredGrid>
{isOffline ? (
<NoServerConnection />
) : isLoading ? (
<CircularProgress color='secondary' />
) : (
!torrents.length && <AddFirstTorrent />
)}
</CenteredGrid>
)
}
return (
<TorrentListWrapper>
{torrents.map(torrent => (
<TorrentCard key={torrent.hash} torrent={torrent} />
))}
</TorrentListWrapper>
)
}

View File

@@ -0,0 +1,30 @@
import styled, { css } from 'styled-components'
export default styled.div`
${({ isButton }) => css`
display: grid;
place-items: center;
padding: 20px 40px;
border-radius: 5px;
${isButton &&
css`
background: #88cdaa;
transition: 0.2s;
cursor: pointer;
:hover {
background: #74c39c;
}
`}
lord-icon {
width: 200px;
height: 200px;
}
.icon-label {
font-size: 20px;
}
`}
`