mirror of
https://github.com/Ernous/TorrServerJellyfin.git
synced 2025-12-19 21:46:11 +05:00
Merge branch 'master' into LGT
This commit is contained in:
17
web/src/components/About/LinkComponent.jsx
Normal file
17
web/src/components/About/LinkComponent.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
web/src/components/About/index.jsx
Normal file
82
web/src/components/About/index.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
121
web/src/components/About/style.js
Normal file
121
web/src/components/About/style.js
Normal 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;
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
87
web/src/components/Add/LeftSideComponent.jsx
Normal file
87
web/src/components/Add/LeftSideComponent.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
183
web/src/components/Add/RightSideComponent.jsx
Normal file
183
web/src/components/Add/RightSideComponent.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
web/src/components/Add/helpers.js
Normal file
55
web/src/components/Add/helpers.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
338
web/src/components/Add/style.js
Normal file
338
web/src/components/Add/style.js
Normal 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);
|
||||
}
|
||||
`}
|
||||
`
|
||||
49
web/src/components/App/Sidebar.jsx
Normal file
49
web/src/components/App/Sidebar.jsx
Normal 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)
|
||||
125
web/src/components/App/index.jsx
Normal file
125
web/src/components/App/index.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
119
web/src/components/App/style.js
Normal file
119
web/src/components/App/style.js
Normal 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;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`}
|
||||
`
|
||||
@@ -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;
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}';
|
||||
|
||||
128
web/src/components/DialogTorrentDetailsContent/widgets/index.jsx
Normal file
128
web/src/components/DialogTorrentDetailsContent/widgets/index.jsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
178
web/src/components/Settings/PrimarySettingsComponent.jsx
Normal file
178
web/src/components/Settings/PrimarySettingsComponent.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
web/src/components/Settings/SecondarySettingsComponent.jsx
Normal file
166
web/src/components/Settings/SecondarySettingsComponent.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
web/src/components/Settings/SettingsDialog.jsx
Normal file
184
web/src/components/Settings/SettingsDialog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
web/src/components/Settings/SliderInput.jsx
Normal file
56
web/src/components/Settings/SliderInput.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
web/src/components/Settings/defaultSettings.js
Normal file
25
web/src/components/Settings/defaultSettings.js
Normal 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,
|
||||
}
|
||||
29
web/src/components/Settings/index.jsx
Normal file
29
web/src/components/Settings/index.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
206
web/src/components/Settings/style.js
Normal file
206
web/src/components/Settings/style.js
Normal 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);
|
||||
}
|
||||
`}
|
||||
`
|
||||
10
web/src/components/Settings/tabComponents.jsx
Normal file
10
web/src/components/Settings/tabComponents.jsx
Normal 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>
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
31
web/src/components/TorrentList/AddFirstTorrent.jsx
Normal file
31
web/src/components/TorrentList/AddFirstTorrent.jsx
Normal 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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
web/src/components/TorrentList/NoServerConnection.jsx
Normal file
22
web/src/components/TorrentList/NoServerConnection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
web/src/components/TorrentList/index.jsx
Normal file
30
web/src/components/TorrentList/index.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
web/src/components/TorrentList/style.js
Normal file
30
web/src/components/TorrentList/style.js
Normal 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;
|
||||
}
|
||||
`}
|
||||
`
|
||||
Reference in New Issue
Block a user