Initial commit: docker compose config
Release Docker multi arch / docker (push) Has been cancelled
Test Install Script / Test Script Syntax (push) Has been cancelled
Test Install Script / Test on almalinux-10 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-10 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (root) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (default) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (root) (push) Has been cancelled
Test Install Script / Test on debian-11 (default) (push) Has been cancelled
Test Install Script / Test on debian-11 (root) (push) Has been cancelled
Test Install Script / Test on debian-12 (default) (push) Has been cancelled
Test Install Script / Test on debian-12 (root) (push) Has been cancelled
Test Install Script / Test on debian-13 (default) (push) Has been cancelled
Test Install Script / Test on debian-13 (root) (push) Has been cancelled
Test Install Script / Test on fedora-latest (default) (push) Has been cancelled
Test Install Script / Test on fedora-latest (root) (push) Has been cancelled
Test Install Script / Test on rocky-10 (default) (push) Has been cancelled
Test Install Script / Test on rocky-10 (root) (push) Has been cancelled
Test Install Script / Test on rocky-8 (default) (push) Has been cancelled
Test Install Script / Test on rocky-8 (root) (push) Has been cancelled
Test Install Script / Test on rocky-9 (default) (push) Has been cancelled
Test Install Script / Test on rocky-9 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (root) (push) Has been cancelled

This commit is contained in:
2026-05-30 12:07:11 +00:00
commit 616c6b1c62
381 changed files with 55145 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"plugins": ["@babel/plugin-transform-react-jsx", "@babel/plugin-proposal-class-properties"],
"env": {
"production": {
"presets": ["minify"]
}
}
}
+2
View File
@@ -0,0 +1,2 @@
REACT_APP_SERVER_HOST=
REACT_APP_TMDB_API_KEY=
+44
View File
@@ -0,0 +1,44 @@
{
"plugins": [ "prettier" ],
"extends": [ "airbnb", "react-app", "react-app/jest", "prettier" ],
"rules": {
"prettier/prettier": ["warn", {
"trailingComma": "all",
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120,
"arrowParens": "avoid", // Allow single argument without parentheses in arrow functions
"semi": false,
"endOfLine": "auto"
}],
"import/no-anonymous-default-export": 0, // Allow "export default"
"import/prefer-default-export": 0,
"import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*.test.js", "**/*.spec.js"]}],
"react/jsx-one-expression-per-line": 0,
"import/order": ["warn", {
"groups": [
"external", // node_modules
"internal", // src folder
["parent", "sibling"]
],
"newlines-between": "always" // Separate all groups with new line
}],
"no-plusplus": 0,
"consistent-return": 0, // returning value is not required in arrow functions
"no-nested-ternary": 0,
"react/require-default-props": 0,
"indent": 0,
"comma-dangle": 0,
"no-shadow": 0, // Allow using same variable name in outer and function scopes
"no-unused-vars": ["warn", {
"vars": "local",
"args": "after-used",
"ignoreRestSiblings": true
}],
"react/prop-types": 0,
"react/react-in-jsx-scope": 0,
"react/jsx-uses-react": 0,
"import/no-unresolved": 0, // used to allow relative paths from "src" folder
"react/jsx-props-no-spreading": 0
}
}
+28
View File
@@ -0,0 +1,28 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# eslint
.eslintcache
.env
+21
View File
@@ -0,0 +1,21 @@
# TorrServer web client
### How to start project
0. ignore first two steps if the server is on `localhost`
1. duplicate `.env_example` and rename it to `.env`
2. in `.env` file add server address to `REACT_APP_SERVER_HOST` (without last "/")
> `http://192.168.78.4:8090` - correct
>
> `http://192.168.78.4:8090/` - wrong
3. in `.env` file add TMDB api key
4. `NODE_OPTIONS=--openssl-legacy-provider yarn start`
### Eslint
> Prettier will fix the code every time the code is saved
- `yarn lint` - to find all linting problems
- `yarn fix` - to fix code
### How images were generated
`npx pwa-asset-generator public/logo.png public -m public/site.webmanifest -p "calc(50vh - 25%) calc(50vw - 25%)" -b "linear-gradient(135deg, rgb(50,54,55), rgb(84,90,94))" -q 100 -i public/index.html -f`
+6
View File
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"baseUrl": "src"
},
"include": ["src"]
}
+70
View File
@@ -0,0 +1,70 @@
{
"name": "torrserver_web",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.11.4",
"@material-ui/icons": "^4.11.2",
"axios": "^1.13.5",
"clsx": "^1.1.1",
"i18next": "^20.3.1",
"i18next-browser-languagedetector": "^6.1.8",
"lodash": "^4.17.23",
"material-ui-image": "^3.3.2",
"parse-torrent": "^9.1.3",
"parse-torrent-title": "^1.3.0",
"polished": "^4.1.3",
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.0.3",
"react-div-100vh": "^0.6.0",
"react-dom": "^17.0.2",
"react-dropzone": "^11.3.2",
"react-i18next": "^11.10.0",
"react-measure": "^2.5.2",
"react-query": "^3.39.3",
"react-scripts": "4.0.3",
"react-swipeable-views": "^0.14.0",
"styled-components": "^5.3.11",
"uuid": "^8.3.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint --ext .js,.jsx src --color",
"fix": "yarn lint --fix"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/cli": "^7.23.0",
"@babel/core": "^7.23.3",
"@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/plugin-transform-react-jsx": "^7.22.15",
"babel-minify": "^0.5.1",
"babel-preset-minify": "^0.5.1",
"eslint": "^7.27.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-prettier": "^3.4.0",
"prettier": "^2.8.8"
},
"description": "",
"main": "gulpfile.js",
"keywords": [],
"author": "",
"license": "ISC",
"homepage": "./"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#00a572</TileColor>
<TileImage src="/mstile-150x150.png" />
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="node-browser-folder" content="/files/" />
<link rel="manifest" href="/site.webmanifest" crossorigin="use-credentials">
<meta name="msapplication-TileColor" content="#00a572">
<meta name='apple-mobile-web-app-status-bar-style' content='black-translucent' >
<meta name="theme-color" content="#ffffff">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600&amp;display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, shrink-to-fit=no, viewport-fit=cover, user-scalable=no">
<meta name="description" content="TorrServer - torrent to http stream">
<title>TorrServer MatriX</title>
<link rel="shortcut icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<meta name="mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="icon.png">
<link rel="apple-touch-startup-image" href="apple-splash-2048-2732.jpg" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2732-2048.jpg" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-1668-2388.jpg" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2388-1668.jpg" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-1536-2048.jpg" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2048-1536.jpg" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-1668-2224.jpg" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2224-1668.jpg" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-1620-2160.jpg" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2160-1620.jpg" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-1284-2778.jpg" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2778-1284.jpg" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-1170-2532.jpg" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2532-1170.jpg" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-1125-2436.jpg" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2436-1125.jpg" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-1242-2688.jpg" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2688-1242.jpg" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-828-1792.jpg" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-1792-828.jpg" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-1242-2208.jpg" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-2208-1242.jpg" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-750-1334.jpg" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-1334-750.jpg" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="apple-splash-640-1136.jpg" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="apple-splash-1136-640.jpg" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script async src="%PUBLIC_URL%/lordicon/lord-icon-2.0.2.js"></script>
<script src="https://www.gstatic.com/firebasejs/8.1.2/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/8.1.2/firebase-analytics.js"></script>
<script>
const firebaseConfig = {
apiKey: "AIzaSyDivIsadtzAmp3SIY4yArNcFugUmr63rvo",
authDomain: "torrserve.firebaseapp.com",
databaseURL: "https://torrserve.firebaseio.com",
projectId: "torrserve",
storageBucket: "torrserve.appspot.com",
messagingSenderId: "400168070412",
appId: "1:400168070412:web:82c8e43dd7fc8f807aed29",
measurementId: "G-T4RC2BFRSF"
};
firebase.initializeApp(firebaseConfig);
firebase.analytics();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "TorrServer",
"short_name": "TorrServer",
"icons": [
{
"src": "icon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "logo.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
@@ -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>
)
}
+118
View File
@@ -0,0 +1,118 @@
import axios from 'axios'
import { useEffect, useState, useMemo } from 'react'
import Button from '@material-ui/core/Button'
import InfoIcon from '@material-ui/icons/Info'
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 { StyledDialog, StyledMenuButtonWrapper } from 'style/CustomMaterialUiStyles'
import { isStandaloneApp } from 'utils/Utils'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
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))
}, [])
const onClose = () => setOpen(false)
const ref = useOnStandaloneAppOutsideClick(onClose)
const basePath = useMemo(() => {
if (typeof window !== 'undefined') {
return window.location.pathname.split('/')[1] || ''
}
return ''
}, [])
return (
<>
<StyledMenuButtonWrapper button key='Settings' onClick={() => setOpen(true)}>
{isStandaloneApp ? (
<>
<InfoIcon />
<div>{t('Details')}</div>
</>
) : (
<>
<ListItemIcon>
<InfoIcon />
</ListItemIcon>
<ListItemText primary={t('About')} />
</>
)}
</StyledMenuButtonWrapper>
<StyledDialog
open={open}
onClose={onClose}
aria-labelledby='form-dialog-title'
fullScreen={fullScreen}
maxWidth='xl'
ref={ref}
>
<DialogWrapper>
<HeaderSection>
<div>{t('About')}</div>
{torrServerVersion}
<img src={`${basePath ? `/${basePath}` : ''}/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' />
<LinkComponent name={t('NasReleases')} link='https://github.com/vladlenas' />
<LinkComponent name={t('ApiDocs')} link='swagger/index.html' />
</div>
</Section>
<Section>
<span>{t('SpecialThanks')}</span>
<div>
<LinkComponent name='Matt Joiner' link='https://github.com/anacrolix' />
<LinkComponent name='Daniel Shleifman' link='https://github.com/dancheskus' />
<LinkComponent name='nikk' link='https://github.com/tsynik' />
<LinkComponent name='kolsys' link='https://github.com/kolsys' />
<LinkComponent name='tw1cker' link='https://github.com/Nemiroff' />
<LinkComponent name='SpAwN_LMG' link='https://github.com/spawnlmg' />
<LinkComponent name='damiva' link='https://github.com/damiva' />
<LinkComponent name='Vladlenas' link='https://github.com/vladlenas' />
<LinkComponent name='Pavel Pikta' link='https://github.com/pavelpikta' />
<LinkComponent name='Anton Potekhin' link='https://github.com/Anton111111' />
<LinkComponent name='FaintGhost' link='https://github.com/FaintGhost' />
<LinkComponent name='TopperBG' link='https://github.com/TopperBG' />
<LinkComponent name='Evgeni' link='https://github.com/lieranderl' />
<LinkComponent name='cocool97' link='https://github.com/cocool97' />
<LinkComponent name='shadeov' link='https://github.com/shadeov' />
<LinkComponent name='Pavel' link='https://github.com/butaford' />
<LinkComponent name='Alexey Filimonov' link='https://github.com/filimonic' />
<LinkComponent name='Viacheslav Evseev' link='https://github.com/leporel' />
</div>
</Section>
</div>
<FooterSection>
<Button onClick={onClose} color='primary' variant='contained'>
{t('Close')}
</Button>
</FooterSection>
</DialogWrapper>
</StyledDialog>
</>
)
}
+126
View File
@@ -0,0 +1,126 @@
import styled, { css } from 'styled-components'
import { standaloneMedia } from 'style/standaloneMedia'
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: 22px;
padding: 10px 20px;
img {
width: 60px;
}
}
${standaloneMedia(css`
padding-top: 30px;
`)}
`
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: 22px;
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: 7px 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(0px);
}
}
`
: css`
cursor: default;
`}
`}
`
export const LinkIcon = styled.div`
display: grid;
margin-right: 10px;
`
+333
View File
@@ -0,0 +1,333 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Button from '@material-ui/core/Button'
import { torrentsHost } 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 ptt from 'parse-torrent-title'
import { ButtonWrapper } from 'style/DialogStyles'
import { StyledDialog, StyledHeader } from 'style/CustomMaterialUiStyles'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
import {
checkImageURL,
getMoviePosters,
checkTorrentSource,
parseTorrentTitle,
shortenTitleForPosterSearch,
} from './helpers'
import { Content } from './style'
import RightSideComponent from './RightSideComponent'
import LeftSideComponent from './LeftSideComponent'
import MultiAddDialog from './MultiAddDialog'
export default function AddDialog({
handleClose,
hash: originalHash,
title: originalTitle,
name: originalName,
poster: originalPoster,
category: originalCategory,
}) {
const { t } = useTranslation()
const isEditMode = !!originalHash
const [torrentSource, setTorrentSource] = useState(originalHash || '')
const [title, setTitle] = useState(originalTitle || '')
const [category, setCategory] = useState(originalCategory || '')
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 [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 editModePosterSearchedRef = useRef(false)
// When files are dropped/selected, switch to MultiAddDialog
const [multiFiles, setMultiFiles] = useState(null)
const ref = useOnStandaloneAppOutsideClick(handleClose)
const { data: torrents } = useQuery('torrents', getTorrents, { retry: 1, refetchInterval: 1000 })
useEffect(() => {
parseTorrent.remote(torrentSource, (_, { infoHash } = {}) => setCurrentSourceHash(infoHash))
}, [torrentSource])
useEffect(() => {
if (!currentSourceHash || !torrents) return
const allHashes = torrents.map(({ hash }) => hash)
setIsHashAlreadyExists(allHashes.includes(currentSourceHash))
}, [currentSourceHash, torrents])
useEffect(() => {
if (!isSaving || !torrents) return
const allHashes = torrents.map(({ hash }) => hash)
allHashes.includes(currentSourceHash) && handleClose()
const linkRegex = /^(http(s?)):\/\/.*/i
torrentSource.match(linkRegex) !== null && handleClose()
}, [isSaving, torrents, torrentSource, currentSourceHash, handleClose])
const fullScreen = useMediaQuery('@media (max-width:930px)')
const updateTitleFromSource = useCallback(() => {
parseTorrentTitle(torrentSource, ({ parsedTitle, originalName }) => {
if (!originalName) return
setSkipDebounce(true)
setTitle('')
setIsCustomTitleEnabled(false)
setOriginalTorrentTitle(originalName)
setParsedTitle(parsedTitle)
})
}, [torrentSource])
useEffect(() => {
if (!torrentSource) {
setTitle('')
setOriginalTorrentTitle('')
setParsedTitle('')
setIsCustomTitleEnabled(false)
setPosterList()
removePoster()
setIsUserInteractedWithPoster(false)
}
}, [torrentSource])
const removePoster = () => {
setIsPosterUrlCorrect(false)
setPosterUrl('')
}
// Edit mode: init original/parsed title from name so poster can be searched
useEffect(() => {
if (!originalHash || (!originalName && !originalTitle)) return
const source = originalName || originalTitle
setOriginalTorrentTitle(source)
try {
const parsed = ptt.parse(source)
setParsedTitle(parsed?.title || '')
} catch (_) {
setParsedTitle('')
}
editModePosterSearchedRef.current = false
}, [originalHash, originalName, originalTitle])
useEffect(() => {
if (originalHash) {
checkImageURL(posterUrl).then(correctImage => {
correctImage ? setIsPosterUrlCorrect(true) : removePoster()
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const posterSearch = useMemo(
() =>
(movieName, language, { shouldRefreshMainPoster = false } = {}) => {
if (!movieName) {
setPosterList()
removePoster()
return
}
const query = shortenTitleForPosterSearch(String(movieName).trim())
getMoviePosters(query || 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 = checkTorrentSource(torrentSource)
if (!isCorrectSource) return setIsTorrentSourceCorrect(false)
setIsTorrentSourceCorrect(true)
const torrentSourceChanged = torrentSource !== prevTorrentSourceState
if (!torrentSourceChanged) return
updateTitleFromSource()
}, [prevTorrentSourceState, torrentSource, updateTitleFromSource])
// Edit mode: auto-search poster once when we have title and no poster
useEffect(() => {
if (
!originalHash ||
editModePosterSearchedRef.current ||
originalPoster ||
!(parsedTitle || originalTitle || title)
) {
return
}
const searchTitle = parsedTitle || title || originalTitle
if (!shortenTitleForPosterSearch(searchTitle)) return
editModePosterSearchedRef.current = true
posterSearch(searchTitle, posterSearchLanguage, { shouldRefreshMainPoster: true })
}, [originalHash, originalPoster, parsedTitle, originalTitle, title, posterSearchLanguage, posterSearch])
const prevTitleState = usePreviousState(title)
useEffect(() => {
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 handleSetSelectedFile = useCallback(fileOrFiles => {
const files = Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles]
setMultiFiles(files)
}, [])
const handleSave = () => {
setIsSaving(true)
if (isEditMode) {
axios
.post(torrentsHost(), {
action: 'set',
hash: originalHash,
title: title || originalName,
poster: posterUrl,
category,
})
.finally(handleClose)
} else {
// link save
axios
.post(torrentsHost(), {
action: 'add',
link: torrentSource,
title,
category,
poster: posterUrl,
save_to_db: true,
})
.catch(handleClose)
}
}
if (multiFiles) {
return <MultiAddDialog files={multiFiles} handleClose={handleClose} />
}
return (
<StyledDialog open onClose={handleClose} fullScreen={fullScreen} fullWidth maxWidth='md' ref={ref}>
<StyledHeader>{t(isEditMode ? 'EditTorrent' : 'AddNewTorrent')}</StyledHeader>
<Content isEditMode={isEditMode}>
{!isEditMode && (
<LeftSideComponent
setIsUserInteractedWithPoster={setIsUserInteractedWithPoster}
setSelectedFile={handleSetSelectedFile}
torrentSource={torrentSource}
setTorrentSource={setTorrentSource}
/>
)}
<RightSideComponent
originalTorrentTitle={originalTorrentTitle}
setTitle={setTitle}
setCategory={setCategory}
setPosterUrl={setPosterUrl}
setIsPosterUrlCorrect={setIsPosterUrlCorrect}
setIsUserInteractedWithPoster={setIsUserInteractedWithPoster}
setPosterList={setPosterList}
isTorrentSourceCorrect={isTorrentSourceCorrect}
isHashAlreadyExists={isHashAlreadyExists}
title={title}
category={category}
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}
/>
</Content>
<ButtonWrapper>
<Button onClick={handleClose} color='secondary' variant='outlined'>
{t('Cancel')}
</Button>
<Button
variant='contained'
style={{ minWidth: '110px' }}
disabled={!torrentSource || (isHashAlreadyExists && !isEditMode) || !isTorrentSourceCorrect}
onClick={handleSave}
color='secondary'
>
{isSaving ? <CircularProgress style={{ color: 'white' }} size={20} /> : t(isEditMode ? 'Save' : 'Add')}
</Button>
</ButtonWrapper>
</StyledDialog>
)
}
@@ -0,0 +1,62 @@
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { AddItemIcon } from 'icons'
import TextField from '@material-ui/core/TextField'
import { useState } from 'react'
import { IconWrapper, LeftSide, LeftSideBottomSectionNoFile, LeftSideTopSection } from './style'
export default function LeftSideComponent({
setIsUserInteractedWithPoster,
setSelectedFile,
torrentSource,
setTorrentSource,
}) {
const { t } = useTranslation()
const handleCapture = files => {
if (!files.length) return
setIsUserInteractedWithPoster(false)
setSelectedFile(files.length === 1 ? files[0] : files)
}
const [isTorrentSourceActive, setIsTorrentSourceActive] = useState(false)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: handleCapture,
accept: '.torrent',
multiple: true,
})
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')}
style={{ marginTop: '1em' }}
type='text'
fullWidth
variant='outlined'
onFocus={() => setIsTorrentSourceActive(true)}
onBlur={() => setIsTorrentSourceActive(false)}
inputProps={{ autoComplete: 'off' }}
/>
</LeftSideTopSection>
<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>
)
}
+235
View File
@@ -0,0 +1,235 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import Button from '@material-ui/core/Button'
import { torrentUploadHost } from 'utils/Hosts'
import axios from 'axios'
import { useTranslation } from 'react-i18next'
import { useMediaQuery, TextField, FormControl, FormHelperText, Select, MenuItem, IconButton } from '@material-ui/core'
import CircularProgress from '@material-ui/core/CircularProgress'
import { Delete as DeleteIcon } from '@material-ui/icons'
import { ButtonWrapper } from 'style/DialogStyles'
import { StyledDialog, StyledHeader } from 'style/CustomMaterialUiStyles'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
import { TORRENT_CATEGORIES } from 'components/categories'
import { NoImageIcon } from 'icons'
import { useQuery } from 'react-query'
import { getTorrents } from 'utils/Utils'
import useChangeLanguage from 'utils/useChangeLanguage'
import parseTorrent from 'parse-torrent'
import { parseTorrentTitle, checkImageURL, getMoviePosters } from './helpers'
import { MultiFileRow, MultiFilePoster, MultiFileInfo, MultiFileList } from './style'
function FileRow({ file, fileState, index, onUpdate, onRemove, existingTorrents }) {
const { t } = useTranslation()
const [currentLang] = useChangeLanguage()
const handleTitleChange = ({ target: { value } }) => onUpdate({ title: value })
const handleCategoryChange = ({ target: { value } }) => onUpdate({ category: value })
const handlePosterChange = ({ target: { value } }) => {
onUpdate({ poster: value })
checkImageURL(value).then(ok => onUpdate({ isPosterOk: ok }))
}
useEffect(() => {
// Extract infohash and check for duplicates
parseTorrent.remote(file, (err, parsed) => {
if (!err && parsed?.infoHash) {
const existing = existingTorrents.find(tor => tor.hash === parsed.infoHash)
if (existing) {
const updates = { infoHash: parsed.infoHash, alreadyExists: true }
if (existing.title) updates.title = existing.title
if (existing.category) updates.category = existing.category
if (existing.poster) {
updates.poster = existing.poster
updates.isPosterOk = true
}
onUpdate(updates)
return
}
onUpdate({ infoHash: parsed.infoHash })
}
})
// Parse title and search poster
const posterLang = currentLang === 'ru' ? 'ru' : 'en'
parseTorrentTitle(file, ({ parsedTitle, originalName }) => {
if (!originalName) return
onUpdate({ originalName, parsedTitle: parsedTitle || '' })
const searchTitle = parsedTitle || originalName
if (searchTitle) {
getMoviePosters(searchTitle, posterLang).then(urls => {
if (urls && urls.length > 0) {
checkImageURL(urls[0]).then(ok => {
if (ok) onUpdate({ poster: urls[0], isPosterOk: true })
})
}
})
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [file])
return (
<MultiFileRow>
<MultiFilePoster>
<span className='file-index'>{index + 1}</span>
{fileState.isPosterOk ? (
<img src={fileState.poster} alt='poster' />
) : (
<NoImageIcon style={{ opacity: 0.3, width: 40, height: 40 }} />
)}
</MultiFilePoster>
<MultiFileInfo>
<TextField
onChange={handleTitleChange}
value={fileState.title}
margin='dense'
label={t('AddDialog.TitleBlank')}
type='text'
variant='outlined'
fullWidth
size='small'
/>
<TextField
onChange={handlePosterChange}
value={fileState.poster}
margin='dense'
label={t('AddDialog.AddPosterLinkInput')}
type='url'
variant='outlined'
fullWidth
size='small'
/>
<FormControl fullWidth size='small' style={{ marginTop: 4 }}>
<FormHelperText style={{ padding: '0 0.5em' }}>{t('AddDialog.CategoryHelperText')}</FormHelperText>
<Select
value={fileState.category}
margin='dense'
onChange={handleCategoryChange}
variant='outlined'
fullWidth
defaultValue=''
>
<MenuItem value=''>
<em></em>
</MenuItem>
{TORRENT_CATEGORIES.map(cat => (
<MenuItem key={cat.key} value={cat.key}>
{t(cat.name)}
</MenuItem>
))}
</Select>
</FormControl>
</MultiFileInfo>
<IconButton onClick={onRemove} size='medium'>
<DeleteIcon />
</IconButton>
</MultiFileRow>
)
}
export default function MultiAddDialog({ files, handleClose }) {
const { t } = useTranslation()
const fullScreen = useMediaQuery('@media (max-width:930px)')
const ref = useOnStandaloneAppOutsideClick(handleClose)
const [isSaving, setIsSaving] = useState(false)
const { data: torrents } = useQuery('torrents', getTorrents, { retry: 1 })
const existingTorrents = torrents || []
const [fileList, setFileList] = useState(() =>
files.map(f => ({
file: f,
title: f.name.replace(/\.torrent$/i, ''),
category: '',
poster: '',
isPosterOk: false,
originalName: '',
parsedTitle: '',
infoHash: '',
alreadyExists: false,
})),
)
const newFiles = useMemo(() => fileList.filter(item => !item.alreadyExists), [fileList])
const newCount = newFiles.length
const handleUpdate = useCallback((index, updates) => {
setFileList(prev => prev.map((item, i) => (i === index ? { ...item, ...updates } : item)))
}, [])
const handleRemove = useCallback(
index => {
setFileList(prev => {
const next = prev.filter((_, i) => i !== index)
if (next.length === 0) handleClose()
return next
})
},
[handleClose],
)
const handleSaveAll = () => {
setIsSaving(true)
const uploads = newFiles.map(item => {
const data = new FormData()
data.append('save', 'true')
data.append('file', item.file)
item.title && data.append('title', item.title)
item.poster && data.append('poster', item.poster)
item.category && data.append('category', item.category)
return axios.post(torrentUploadHost(), data).catch(() => {})
})
Promise.all(uploads).finally(handleClose)
}
return (
<StyledDialog open onClose={handleClose} fullScreen={fullScreen} fullWidth maxWidth='md' ref={ref}>
<StyledHeader>
{t('AddNewTorrent')} ({newCount})
</StyledHeader>
<MultiFileList>
{(() => {
let visibleIndex = 0
return fileList.map((item, index) => {
if (item.alreadyExists) return null
const currentIndex = visibleIndex++
return (
<FileRow
// eslint-disable-next-line react/no-array-index-key
key={item.file.name + index}
file={item.file}
fileState={item}
index={currentIndex}
onUpdate={updates => handleUpdate(index, updates)}
onRemove={() => handleRemove(index)}
existingTorrents={existingTorrents}
/>
)
})
})()}
</MultiFileList>
<ButtonWrapper>
<Button onClick={handleClose} color='secondary' variant='outlined'>
{t('Cancel')}
</Button>
<Button
variant='contained'
style={{ minWidth: '110px' }}
disabled={newCount === 0}
onClick={handleSaveAll}
color='secondary'
>
{isSaving ? <CircularProgress style={{ color: 'white' }} size={20} /> : `${t('Add')} (${newCount})`}
</Button>
</ButtonWrapper>
</StyledDialog>
)
}
@@ -0,0 +1,263 @@
import { useTranslation } from 'react-i18next'
import { rgba } from 'polished'
import { NoImageIcon } from 'icons'
import {
FormControl,
FormHelperText,
IconButton,
InputAdornment,
MenuItem,
Select,
TextField,
useTheme,
} from '@material-ui/core'
import { HighlightOff as HighlightOffIcon } from '@material-ui/icons'
import { TORRENT_CATEGORIES } from 'components/categories'
import {
ClearPosterButton,
UpdatePosterButton,
PosterLanguageSwitch,
RightSide,
Poster,
PosterSuggestions,
PosterSuggestionsItem,
PosterWrapper,
RightSideContainer,
} from './style'
import { checkImageURL } from './helpers'
export default function RightSideComponent({
setTitle,
setCategory,
setPosterUrl,
setIsPosterUrlCorrect,
setIsUserInteractedWithPoster,
setPosterList,
isTorrentSourceCorrect,
isHashAlreadyExists,
title,
category,
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 handleCategoryChange = ({ target: { value } }) => setCategory(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)
}
const catIndex = TORRENT_CATEGORIES.findIndex(e => e.key === category)
return (
<RightSide>
<RightSideContainer isHidden={!isTorrentSourceCorrect || (isHashAlreadyExists && !isEditMode)}>
{originalTorrentTitle ? (
<>
<TextField
value={originalTorrentTitle}
margin='dense'
label={t('AddDialog.OriginalTorrentTitle')}
style={{ marginTop: '1em' }}
type='text'
variant='outlined'
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'
variant='outlined'
fullWidth
helperText={t('AddDialog.CustomTorrentTitleHelperText')}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
<IconButton
size='small'
style={{ padding: '1px', marginRight: '-6px' }}
onClick={() => {
setTitle('')
setIsCustomTitleEnabled(!isCustomTitleEnabled)
updateTitleFromSource()
setIsUserInteractedWithPoster(false)
}}
>
<HighlightOffIcon style={{ color: isCustomTitleEnabled ? primary : rgba('#ccc', 0.25) }} />
</IconButton>
</InputAdornment>
),
}}
/>
</>
) : (
<TextField
onChange={handleTitleChange}
value={title}
margin='dense'
label={t('AddDialog.TitleBlank')}
style={{ marginTop: '1em' }}
type='text'
variant='outlined'
fullWidth
helperText={t('AddDialog.TitleBlankHelperText')}
/>
)}
<TextField
onChange={handlePosterUrlChange}
value={posterUrl}
margin='dense'
label={t('AddDialog.AddPosterLinkInput')}
type='url'
variant='outlined'
fullWidth
/>
<FormControl fullWidth>
<FormHelperText style={{ padding: '0.2em 1.2em 0.5em 1.2em' }}>
{t('AddDialog.CategoryHelperText')}
</FormHelperText>
<Select
labelId='torrent-category-select-label'
id='torrent-category-select'
value={category}
margin='dense'
onChange={handleCategoryChange}
variant='outlined'
fullWidth
defaultValue=''
IconComponent={
category.length > 1
? () => (
<IconButton
size='small'
style={{ padding: '1px', marginLeft: '6px', marginRight: '8px' }}
onClick={() => {
setCategory('')
}}
>
<HighlightOffIcon style={{ color: primary }} />
</IconButton>
)
: undefined
}
>
{category.length > 1 && catIndex < 0 ? (
<MenuItem key={category} value={category}>
{category}
</MenuItem>
) : (
''
)}
{TORRENT_CATEGORIES.map(category => (
<MenuItem key={category.key} value={category.key}>
{t(category.name)}
</MenuItem>
))}
</Select>
</FormControl>
<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>
<UpdatePosterButton
onClick={() => {
const fixedTitle = isCustomTitleEnabled ? title : originalTorrentTitle ? parsedTitle : title
posterSearch(fixedTitle, posterSearchLanguage)
}}
color='primary'
variant='contained'
size='small'
>
{t('Update')}
</UpdatePosterButton>
</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>
)
}
+225
View File
@@ -0,0 +1,225 @@
import React, { useState, useMemo } from 'react'
import {
TextField,
Button,
List,
ListItem,
ListItemText,
CircularProgress,
Typography,
Divider,
ListItemSecondaryAction,
IconButton,
Select,
MenuItem,
FormControl,
InputLabel,
useMediaQuery,
} from '@material-ui/core'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import { torznabSearchHost } from 'utils/Hosts'
import { AddCircleOutline as AddIcon, ArrowUpward, ArrowDownward } from '@material-ui/icons'
import { parseSizeToBytes, formatSizeToClassicUnits } from 'utils/Utils'
export default function TorznabSearch({ onSelect }) {
const { t } = useTranslation()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [sortField, setSortField] = useState('') // '', 'size', 'seeds', 'peers'
const [sortDirection, setSortDirection] = useState('desc') // 'asc' or 'desc'
const isMobile = useMediaQuery('(max-width:600px)')
const handleSearch = async () => {
if (!query) return
setLoading(true)
setSearched(true)
try {
const { data } = await axios.get(torznabSearchHost(), { params: { query } })
setResults(data || [])
} catch (error) {
setResults([])
} finally {
setLoading(false)
}
}
const handleKeyDown = e => {
if (e.key === 'Enter') {
handleSearch()
}
}
const toggleSortDirection = () => {
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'))
}
const sortedResults = useMemo(() => {
if (!sortField || results.length === 0) return results
const sorted = [...results].sort((a, b) => {
let aVal
let bVal
switch (sortField) {
case 'size':
aVal = parseSizeToBytes(a.Size || '0')
bVal = parseSizeToBytes(b.Size || '0')
break
case 'seeds':
aVal = a.Seed || 0
bVal = b.Seed || 0
break
case 'peers':
aVal = a.Peer || 0
bVal = b.Peer || 0
break
default:
return 0
}
if (aVal === bVal) return 0
return sortDirection === 'asc' ? (aVal < bVal ? -1 : 1) : aVal > bVal ? -1 : 1
})
return sorted
}, [results, sortField, sortDirection])
return (
<div style={{ marginTop: '1.5em' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: isMobile ? 'wrap' : 'nowrap' }}>
<TextField
label={t('Torznab.SearchTorznab')}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
variant='outlined'
size='small'
fullWidth
placeholder={t('Torznab.SearchMoviesShows')}
style={{ flex: isMobile ? '1 1 100%' : '1' }}
/>
<Button
variant='contained'
color='primary'
onClick={handleSearch}
disabled={loading}
style={{
minWidth: isMobile ? '100%' : '80px',
flex: isMobile ? '1 1 100%' : '0 0 auto',
}}
>
{loading ? <CircularProgress size={24} color='inherit' /> : t('Torznab.SearchTorrents')}
</Button>
</div>
{searched && (
<div style={{ marginTop: '8px' }}>
{results.length > 0 && (
<div
style={{
display: 'flex',
gap: isMobile ? '8px' : '4px',
marginBottom: '12px',
alignItems: 'center',
padding: isMobile ? '12px 8px' : '8px 12px',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
borderRadius: '4px',
border: '1px solid rgba(0, 0, 0, 0.08)',
flexWrap: isMobile ? 'wrap' : 'nowrap',
}}
>
<FormControl
variant='outlined'
size='small'
style={{
minWidth: isMobile ? '100%' : 140,
flexShrink: 0,
flex: isMobile ? '1 1 100%' : '0 0 auto',
}}
>
<InputLabel>{t('Torznab.SortBy')}</InputLabel>
<Select value={sortField} onChange={e => setSortField(e.target.value)} label={t('Torznab.SortBy')}>
<MenuItem value=''>{t('Torznab.SortByNone')}</MenuItem>
<MenuItem value='size'>{t('Torznab.SortBySize')}</MenuItem>
<MenuItem value='seeds'>{t('Torznab.SortBySeeds')}</MenuItem>
<MenuItem value='peers'>{t('Torznab.SortByPeers')}</MenuItem>
</Select>
</FormControl>
{sortField && (
<IconButton
size='small'
onClick={toggleSortDirection}
title={sortDirection === 'asc' ? t('Torznab.SortAscending') : t('Torznab.SortDescending')}
style={{
marginLeft: isMobile ? 'auto' : '4px',
padding: '8px',
}}
>
{sortDirection === 'asc' ? <ArrowUpward /> : <ArrowDownward />}
</IconButton>
)}
</div>
)}
<div
style={{
maxHeight: isMobile ? '300px' : '200px',
overflowY: 'auto',
border: '1px solid rgba(0,0,0,0.12)',
borderRadius: '4px',
}}
>
{results.length === 0 ? (
<div style={{ padding: '8px', textAlign: 'center' }}>
<Typography variant='body2'>
{loading ? t('Torznab.SearchTorrents') : t('Torznab.NoResultsFound')}
</Typography>
</div>
) : (
<List dense>
{sortedResults.map((item, index) => {
const sizeBytes = parseSizeToBytes(item.Size || '0')
const formattedSize = formatSizeToClassicUnits(sizeBytes)
return (
<React.Fragment key={item.Hash || item.Link || index}>
<ListItem button onClick={() => onSelect(item.Magnet || item.Link)}>
<ListItemText
primary={item.Title}
secondary={`${formattedSize} • S:${item.Seed || 0} P:${item.Peer || 0}`}
primaryTypographyProps={{
noWrap: !isMobile,
style: {
fontSize: isMobile ? '0.85rem' : '0.9rem',
whiteSpace: isMobile ? 'normal' : 'nowrap',
},
}}
secondaryTypographyProps={{
style: {
fontSize: isMobile ? '0.75rem' : '0.8rem',
},
}}
/>
<ListItemSecondaryAction>
<IconButton
edge='end'
aria-label='add'
onClick={() => onSelect(item.Magnet || item.Link)}
size={isMobile ? 'small' : 'medium'}
>
<AddIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
</React.Fragment>
)
})}
</List>
)}
</div>
</div>
)}
</div>
)
}
+147
View File
@@ -0,0 +1,147 @@
import axios from 'axios'
import parseTorrent from 'parse-torrent'
import ptt from 'parse-torrent-title'
import { tmdbSettingsHost } from 'utils/Hosts'
// Cache for TMDB settings to avoid repeated API calls
let tmdbSettingsCache = null
// Clear TMDB settings cache - call this when settings are updated
export const clearTMDBCache = () => {
tmdbSettingsCache = null
}
// Fetch TMDB settings from backend
const getTMDBSettings = async () => {
if (tmdbSettingsCache) {
return tmdbSettingsCache
}
try {
const { data } = await axios.get(tmdbSettingsHost())
tmdbSettingsCache = data
return data
} catch (error) {
return {
APIKey: process.env.REACT_APP_TMDB_API_KEY || '',
APIURL: 'https://api.themoviedb.org/3',
ImageURL: 'https://image.tmdb.org',
ImageURLRu: 'https://imagetmdb.com',
}
}
}
export const getMoviePosters = async (movieName, language = 'en') => {
const settings = await getTMDBSettings()
// If no API key is configured, return null
if (!settings.APIKey) {
return null
}
// Build API URL - automatically append /3/search/multi
let apiURL = settings.APIURL.replace(/^https?:\/\//, '').replace(/\/$/, '')
// If URL doesn't already contain the full path, add /3/search/multi
if (!apiURL.includes('/3/search/multi')) {
// Remove any partial paths that might exist
apiURL = apiURL.replace(/\/3.*$/, '').replace(/\/search.*$/, '')
apiURL = `${apiURL}/3/search/multi`
}
const url = `${window.location.protocol}//${apiURL}`
// Build image URL - strip protocol and trailing slash
const imgHost = `${window.location.protocol}//${
language === 'ru'
? settings.ImageURLRu.replace(/^https?:\/\//, '').replace(/\/$/, '')
: settings.ImageURL.replace(/^https?:\/\//, '').replace(/\/$/, '')
}`
return axios
.get(url, {
params: {
api_key: settings.APIKey,
language,
include_image_language: `${language},null,en`,
query: movieName,
},
})
.then(({ data: { results } }) =>
results.filter(el => el.poster_path).map(el => `${imgHost}/t/p/w300${el.poster_path}`),
)
.catch(() => null)
}
export const checkImageURL = async url => {
if (!url || !url.match(/.(\.jpg|\.jpeg|\.png|\.gif|\.svg||\.webp).*$/i)) return false
return true
}
const magnetRegex = /^magnet:\?xt=urn:[a-z0-9].*/i
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
const linkRegex = /^(http(s?)):\/\/.*/i
const torrsRegex = /^(torrs):\/\/.*/i
export const checkTorrentSource = source =>
source.match(hashRegex) !== null ||
source.match(magnetRegex) !== null ||
source.match(torrentRegex) !== null ||
source.match(linkRegex) !== null ||
source.match(torrsRegex) !== null
/** Max length for TMDB/search API query; long torrent names exceed this. */
const POSTER_SEARCH_MAX_LEN = 50
/** Max words to use from title for poster search. */
const POSTER_SEARCH_MAX_WORDS = 4
/**
* Shortens a long torrent title for poster search (TMDB).
* Uses part before " [", " (", " / " and limits by words/length so the API gets a valid query.
* @param {string} fullTitle - Raw torrent title
* @param {{ maxWords?: number, maxLen?: number }} opts - Optional limits
* @returns {string} Short title suitable for getMoviePosters()
*/
export const shortenTitleForPosterSearch = (fullTitle, opts = {}) => {
const maxWords = opts.maxWords ?? POSTER_SEARCH_MAX_WORDS
const maxLen = opts.maxLen ?? POSTER_SEARCH_MAX_LEN
if (!fullTitle || typeof fullTitle !== 'string') return ''
const trimmed = fullTitle.trim()
if (!trimmed) return ''
let base = trimmed
for (const sep of [' [', ' (', ' / ']) {
const i = base.indexOf(sep)
if (i > 0) base = base.slice(0, i).trim()
}
try {
const parsed = ptt.parse(base)
if (parsed?.title && parsed.title.length <= maxLen + 15) base = parsed.title
} catch (_) {
// ignore
}
const words = base.split(/\s+/).filter(Boolean)
const byWords = words.slice(0, maxWords).join(' ')
if (byWords.length <= maxLen) return byWords.trim()
const cut = byWords.slice(0, maxLen)
const lastSpace = cut.lastIndexOf(' ')
const result = lastSpace > 0 ? cut.slice(0, lastSpace) : cut
return result.trim() || trimmed.slice(0, maxLen).trim()
}
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 })
})
}
+37
View File
@@ -0,0 +1,37 @@
import { useState } from 'react'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import LibraryAddIcon from '@material-ui/icons/LibraryAdd'
import ListItemText from '@material-ui/core/ListItemText'
import { useTranslation } from 'react-i18next'
import { StyledMenuButtonWrapper } from 'style/CustomMaterialUiStyles'
import { isStandaloneApp } from 'utils/Utils'
import AddDialog from './AddDialog'
import { StyledPWAAddButton } from './style'
export default function AddDialogButton({ isOffline, isLoading }) {
const { t } = useTranslation()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const handleClickOpen = () => setIsDialogOpen(true)
const handleClose = () => setIsDialogOpen(false)
return (
<div>
<StyledMenuButtonWrapper disabled={isOffline || isLoading} button onClick={handleClickOpen}>
{isStandaloneApp ? (
<StyledPWAAddButton />
) : (
<>
<ListItemIcon>
<LibraryAddIcon />
</ListItemIcon>
<ListItemText primary={t('AddFromLink')} />
</>
)}
</StyledMenuButtonWrapper>
{isDialogOpen && <AddDialog handleClose={handleClose} />}
</div>
)
}
+412
View File
@@ -0,0 +1,412 @@
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;
outline: none;
${({ 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 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: flex-start;
transform: translateY(-50%);
position: absolute;
${({ showbutton }) => !showbutton && 'display: none'};
@media (max-width: 540px) {
transform: translateY(-140%);
}
`
export const UpdatePosterButton = styled(Button)`
grid-area: clear;
justify-self: flex-end;
transform: translateY(-50%);
position: absolute;
@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);
}
`}
`
export const MultiFileRow = styled.div`
padding: 12px 16px;
margin: 8px 12px;
border-radius: 5px;
box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.12), 0px 1px 2px rgba(0, 0, 0, 0.08);
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 12px;
align-items: start;
transition: box-shadow 0.2s;
:hover {
box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.16), 0px 1px 4px rgba(0, 0, 0, 0.12);
}
`
export const MultiFilePoster = styled.div`
${({
theme: {
addDialog: { posterBGColor },
},
}) => css`
width: 80px;
height: 110px;
border-radius: 4px;
overflow: hidden;
background: ${posterBGColor};
display: grid;
place-items: center;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-index {
position: absolute;
top: 4px;
left: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 11px;
font-weight: 600;
display: grid;
place-items: center;
}
`}
`
export const MultiFileInfo = styled.div`
.file-name {
font-size: 13px;
opacity: 0.6;
margin-bottom: 4px;
}
`
export const MultiFileList = styled.div`
max-height: 500px;
overflow: auto;
padding: 4px 0;
`
export const StyledPWAAddButton = styled.div`
border: 2px solid white;
border-radius: 50%;
height: 45px;
width: 45px;
position: relative;
:before,
:after {
content: '';
background: white;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
:before {
width: 2px;
height: 25px;
}
:after {
width: 25px;
height: 2px;
}
`
@@ -0,0 +1,31 @@
import { CreditCard as CreditCardIcon } from '@material-ui/icons'
import { useTranslation } from 'react-i18next'
import CloseServer from 'components/CloseServer'
import { StyledMenuButtonWrapper } from 'style/CustomMaterialUiStyles'
import AddDialogButton from 'components/Add'
import AboutDialog from 'components/About'
import SettingsDialogButton from 'components/Settings'
import StyledPWAFooter from './style'
export default function PWAFooter({ setIsDonationDialogOpen, isOffline, isLoading }) {
const { t } = useTranslation()
return (
<StyledPWAFooter>
<CloseServer isOffline={isOffline} isLoading={isLoading} />
<StyledMenuButtonWrapper onClick={() => setIsDonationDialogOpen(true)}>
<CreditCardIcon />
<div>{t('Donate')}</div>
</StyledMenuButtonWrapper>
<AddDialogButton isOffline={isOffline} isLoading={isLoading} />
<AboutDialog />
<SettingsDialogButton isOffline={isOffline} isLoading={isLoading} />
</StyledPWAFooter>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { standaloneMedia } from 'style/standaloneMedia'
import styled, { css } from 'styled-components'
export const pwaFooterHeight = 90
export default styled.div`
background: #575757;
color: #fff;
position: fixed;
bottom: 0;
width: 100%;
height: ${pwaFooterHeight}px;
display: none;
${standaloneMedia(css`
display: grid;
grid-template-columns: repeat(5, calc(100% / 5));
justify-items: center;
`)}
`
@@ -0,0 +1,21 @@
export default function IOSShareIcon() {
return (
<svg
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
width={23}
x='0px'
y='0px'
viewBox='0 0 1000 1000'
enableBackground='new 0 0 1000 1000'
xmlSpace='preserve'
fill='#005FF2'
>
<metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
<g>
<path d='M780,290H640v35h140c19.3,0,35,15.7,35,35v560c0,19.3-15.7,35-35,35H220c-19.2,0-35-15.7-35-35V360c0-19.2,15.7-35,35-35h140v-35H220c-38.7,0-70,31.3-70,70v560c0,38.7,31.3,70,70,70h560c38.7,0,70-31.3,70-70V360C850,321.3,818.7,290,780,290z M372.5,180l110-110.2v552.7c0,9.6,7.9,17.5,17.5,17.5c9.6,0,17.5-7.9,17.5-17.5V69.8l110,110c3.5,3.5,7.9,5,12.5,5s9-1.7,12.5-5c6.8-6.8,6.8-17.9,0-24.7l-140-140c-6.8-6.8-17.9-6.8-24.7,0l-140,140c-6.8,6.8-6.8,17.9,0,24.7C354.5,186.8,365.5,186.8,372.5,180z' />
</g>
</svg>
)
}
@@ -0,0 +1,57 @@
import IconButton from '@material-ui/core/IconButton'
import CloseIcon from '@material-ui/icons/Close'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import IOSShareIcon from './IOSShareIcon'
import { StyledWrapper, StyledHeader, StyledContent } from './style'
export function PWAInstallationGuide() {
const pwaNotificationIsClosed = JSON.parse(localStorage.getItem('pwaNotificationIsClosed'))
const [isOpen, setIsOpen] = useState(!pwaNotificationIsClosed)
const [shouldBeOpened, setShouldBeOpened] = useState(!pwaNotificationIsClosed)
const { t } = useTranslation()
if (!isOpen) return null
return (
<StyledWrapper isOpen={shouldBeOpened}>
<StyledHeader>
<img src='/icon.png' width={50} alt='ts-icon' />
{t('PWAGuide.Header')}
<IconButton
size='small'
aria-label='close'
color='inherit'
onClick={() => {
setShouldBeOpened(false)
setTimeout(() => {
setIsOpen(false)
localStorage.setItem('pwaNotificationIsClosed', true)
}, 300)
}}
>
<CloseIcon fontSize='small' />
</IconButton>
</StyledHeader>
<StyledContent>
<p>{t('PWAGuide.Description')}</p>
<p>{t('PWAGuide.PlayerButtons')}</p>
<p>
1. {t('PWAGuide.FirstStep')} <IOSShareIcon />
</p>
<p>
2. {t('PWAGuide.SecondStep.Select')} <span>{t('PWAGuide.SecondStep.AddToHomeScreen')}</span>
</p>
</StyledContent>
</StyledWrapper>
)
}
@@ -0,0 +1,59 @@
import styled, { css } from 'styled-components'
export const StyledWrapper = styled.div`
${({ isOpen }) => css`
position: absolute;
bottom: 10px;
left: 50%;
background: #eeeef0;
width: calc(100% - 20px);
z-index: 9999;
border-radius: 10px;
transition: all 0.3s;
color: #000;
${isOpen
? css`
opacity: 1;
transform: translate(-50%, 0);
`
: css`
transform: translate(-50%, 150%);
opacity: 0;
pointer-events: none;
`}
> :not(:last-child) {
border-bottom: 1px solid #dadadc;
}
> * {
padding: 20px;
}
`}
`
export const StyledHeader = styled.div`
display: grid;
grid-auto-flow: column;
grid-template-columns: min-content 1fr;
gap: 20px;
align-items: center;
font-weight: 700;
img {
border-radius: 5px;
}
`
export const StyledContent = styled.div`
> :not(:last-child) {
margin-bottom: 25px;
}
span {
background: #fefcfd;
padding: 5px;
border-radius: 5px;
}
`
+83
View File
@@ -0,0 +1,83 @@
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 SearchDialogButton from 'components/Search'
import { memo } from 'react'
import CheckIcon from '@material-ui/icons/Check'
import ClearIcon from '@material-ui/icons/Clear'
import { TORRENT_CATEGORIES } from 'components/categories'
import FilterByCategory from 'components/FilterByCategory'
import { AppSidebarStyle } from './style'
const Sidebar = ({ isDrawerOpen, setIsDonationDialogOpen, isOffline, isLoading, setGlobalFilterCategory }) => {
const { t } = useTranslation()
return (
<AppSidebarStyle isDrawerOpen={isDrawerOpen}>
<List>
<AddDialogButton isOffline={isOffline} isLoading={isLoading} />
<SearchDialogButton isOffline={isOffline} isLoading={isLoading} />
<RemoveAll isOffline={isOffline} isLoading={isLoading} />
</List>
<Divider />
<List>
<FilterByCategory
key='all'
categoryKey='all'
categoryName={t('All')}
icon={<CheckIcon />}
setGlobalFilterCategory={setGlobalFilterCategory}
/>
{TORRENT_CATEGORIES.map(category => (
<FilterByCategory
key={category.key}
categoryKey={category.key}
categoryName={t(category.name)}
icon={category.icon}
setGlobalFilterCategory={setGlobalFilterCategory}
/>
))}
<FilterByCategory
key='uncategorized'
categoryKey=''
categoryName={t('Uncategorized')}
icon={<ClearIcon />}
setGlobalFilterCategory={setGlobalFilterCategory}
/>
</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)
+181
View File
@@ -0,0 +1,181 @@
import CssBaseline from '@material-ui/core/CssBaseline'
import { createContext, useEffect, useState } from 'react'
import Typography from '@material-ui/core/Typography'
import {
Menu as MenuIcon,
Close as CloseIcon,
Brightness4 as Brightness4Icon,
Brightness5 as Brightness5Icon,
BrightnessAuto as BrightnessAutoIcon,
Sort as SortIcon,
SortByAlpha as SortByAlphaIcon,
Search as SearchIcon,
} 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, isStandaloneApp } from 'utils/Utils'
import GlobalStyle from 'style/GlobalStyle'
import { /* lightTheme, */ THEME_MODES, useMaterialUITheme } from 'style/materialUISetup'
import getStyledComponentsTheme from 'style/getStyledComponentsTheme'
import checkIsIOS from 'utils/checkIsIOS'
import SearchDialog from 'components/Search/SearchDialog'
import { AppWrapper, AppHeader, HeaderToggle, StyledIconButton, SidebarOverlay } from './style'
import Sidebar from './Sidebar'
import PWAFooter from './PWAFooter'
import { PWAInstallationGuide } from './PWAInstallationGuide'
const snackbarIsClosed = JSON.parse(localStorage.getItem('snackbarIsClosed'))
export const DarkModeContext = createContext()
export default function App() {
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const [isDonationDialogOpen, setIsDonationDialogOpen] = useState(false)
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false)
const [torrServerVersion, setTorrServerVersion] = useState('')
const [isDarkMode, currentThemeMode, updateThemeMode, muiTheme] = useMaterialUITheme()
const [currentLang, changeLang] = useChangeLanguage()
const [isOffline, setIsOffline] = useState(false)
const [globalCategoryFilter, setGlobalFilterCategory] = useState('all')
const { data: torrents, isLoading } = useQuery('torrents', getTorrents, {
retry: 1,
refetchInterval: 1000,
onError: () => setIsOffline(true),
onSuccess: () => setIsOffline(false),
})
const [sortABC, setSortABC] = useState(false)
const handleClickSortABC = () => setSortABC(true)
const handleClickSortDate = () => setSortABC(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 isDrawerOpen={isDrawerOpen}>
<AppHeader>
<StyledIconButton edge='start' color='inherit' onClick={() => setIsDrawerOpen(!isDrawerOpen)}>
{isDrawerOpen ? <CloseIcon /> : <MenuIcon />}
</StyledIconButton>
<Typography variant='h6' noWrap>
TorrServer {torrServerVersion}
</Typography>
<div
style={{
justifySelf: 'end',
display: 'grid',
gridTemplateColumns: isStandaloneApp ? 'repeat(4, 1fr)' : 'repeat(3, 1fr)',
gap: '10px',
}}
>
{isStandaloneApp && (
<HeaderToggle onClick={() => setIsSearchDialogOpen(true)}>
<SearchIcon />
</HeaderToggle>
)}
<HeaderToggle onClick={() => (sortABC === true ? handleClickSortDate() : handleClickSortABC())}>
{sortABC === true ? <SortByAlphaIcon /> : <SortIcon />}
</HeaderToggle>
<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')
: currentLang === 'ru'
? changeLang('ua')
: currentLang === 'ua'
? changeLang('zh')
: currentLang === 'zh'
? changeLang('bg')
: currentLang === 'bg'
? changeLang('fr')
: currentLang === 'fr'
? changeLang('ro')
: changeLang('en')
}
>
{currentLang.toUpperCase()}
</HeaderToggle>
</div>
</AppHeader>
<SidebarOverlay isDrawerOpen={isDrawerOpen} onClick={() => setIsDrawerOpen(false)} />
<Sidebar
isOffline={isOffline}
isLoading={isLoading}
isDrawerOpen={isDrawerOpen}
setIsDonationDialogOpen={setIsDonationDialogOpen}
setGlobalFilterCategory={setGlobalFilterCategory}
/>
<TorrentList
isOffline={isOffline}
torrents={torrents}
isLoading={isLoading}
sortABC={sortABC}
sortCategory={globalCategoryFilter}
/>
<PWAFooter
isOffline={isOffline}
isLoading={isLoading}
setIsDonationDialogOpen={setIsDonationDialogOpen}
/>
{/* <MuiThemeProvider theme={lightTheme}> */}
{isDonationDialogOpen && <DonateDialog onClose={() => setIsDonationDialogOpen(false)} />}
{/* </MuiThemeProvider> */}
{isSearchDialogOpen && <SearchDialog handleClose={() => setIsSearchDialogOpen(false)} />}
{snackbarIsClosed ? checkIsIOS() && !isStandaloneApp && <PWAInstallationGuide /> : <DonateSnackbar />}
</AppWrapper>
</Div100vh>
</StyledComponentsThemeProvider>
</MuiThemeProvider>
</DarkModeContext.Provider>
</>
)
}
+198
View File
@@ -0,0 +1,198 @@
import { IconButton } from '@material-ui/core'
import { rgba } from 'polished'
import { standaloneMedia } from 'style/standaloneMedia'
import styled, { css } from 'styled-components'
import { pwaFooterHeight } from './PWAFooter/style'
export const AppWrapper = styled.div`
${({
isDrawerOpen,
theme: {
app: { appSecondaryColor },
},
}) => css`
height: 100%;
background: ${rgba(appSecondaryColor, 0.8)};
display: grid;
grid-template-columns: ${isDrawerOpen ? '240px' : '60px'} 1fr;
grid-template-rows: 60px 1fr;
grid-template-areas:
'head head'
'side content';
transition: grid-template-columns 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms;
@media (max-width: 700px) {
grid-template-columns: 0 1fr;
}
${standaloneMedia(css`
grid-template-columns: 0 1fr;
grid-template-rows: ${pwaFooterHeight}px 1fr ${pwaFooterHeight}px;
height: 100vh;
`)}
`}
`
export const CenteredGrid = styled.div`
display: grid;
place-items: center;
${standaloneMedia(css`
height: 100vh;
width: 100vw;
`)}
`
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;
${standaloneMedia(css`
grid-template-columns: max-content 1fr;
align-items: end;
padding: 7px 16px;
position: fixed;
width: 100%;
height: ${pwaFooterHeight}px;
`)}
`}
`
export const AppSidebarStyle = styled.div`
${({
isDrawerOpen,
theme: {
app: { appSecondaryColor, sidebarBGColor, sidebarFillColor },
},
}) => css`
grid-area: side;
z-index: 2;
overflow-x: hidden;
border-right: 1px solid ${rgba(appSecondaryColor, 0.12)};
background: ${sidebarBGColor};
color: ${sidebarFillColor};
white-space: nowrap;
/* hide scrollbars */
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
::-webkit-scrollbar {
display: none; /* Safari and Chrome */
width: 0; /* Remove scrollbar space */
background: transparent;
}
svg {
fill: ${sidebarFillColor};
}
@media (max-width: 700px) {
position: fixed;
top: 60px;
left: 0;
bottom: 0;
width: 240px;
transform: translateX(${isDrawerOpen ? '0' : '-100%'});
transition: transform 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms;
box-shadow: ${isDrawerOpen ? '2px 0 8px rgba(0,0,0,0.3)' : 'none'};
}
${standaloneMedia(css`
display: none;
`)}
`}
`
export const TorrentListWrapper = styled.div`
grid-area: content;
padding: 20px;
overflow: auto;
display: grid;
place-content: start;
grid-template-columns: repeat(auto-fit, minmax(450px, 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;
}
${standaloneMedia(css`
height: calc(100vh - ${pwaFooterHeight}px);
padding-bottom: 105px;
`)}
`
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;
}
}
`}
`
export const SidebarOverlay = styled.div`
display: none;
@media (max-width: 700px) {
display: ${({ isDrawerOpen }) => (isDrawerOpen ? 'block' : 'none')};
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
`
export const StyledIconButton = styled(IconButton)`
margin-right: 6px;
${standaloneMedia(css`
display: none;
`)}
`
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { Button, DialogActions, DialogTitle, ListItemIcon, ListItemText } from '@material-ui/core'
import { StyledDialog, StyledMenuButtonWrapper } from 'style/CustomMaterialUiStyles'
import { PowerSettingsNew as PowerSettingsNewIcon, PowerOff as PowerOffIcon } from '@material-ui/icons'
import { shutdownHost } from 'utils/Hosts'
import { useTranslation } from 'react-i18next'
import { isStandaloneApp } from 'utils/Utils'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
import UnsafeButton from './UnsafeButton'
export default function CloseServer({ isOffline, isLoading }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const closeDialog = () => setOpen(false)
const openDialog = () => setOpen(true)
const ref = useOnStandaloneAppOutsideClick(closeDialog)
return (
<>
<StyledMenuButtonWrapper disabled={isOffline || isLoading} button key={t('CloseServer')} onClick={openDialog}>
{isStandaloneApp ? (
<>
<PowerSettingsNewIcon />
<div>{t('TurnOff')}</div>
</>
) : (
<>
<ListItemIcon>
<PowerSettingsNewIcon />
</ListItemIcon>
<ListItemText primary={t('CloseServer')} />
</>
)}
</StyledMenuButtonWrapper>
<StyledDialog open={open} onClose={closeDialog} ref={ref}>
<DialogTitle>{t('CloseServer?')}</DialogTitle>
<DialogActions>
<Button variant='outlined' onClick={closeDialog} color='secondary'>
{t('Cancel')}
</Button>
<UnsafeButton
timeout={5}
startIcon={<PowerOffIcon />}
variant='contained'
onClick={() => {
fetch(shutdownHost())
closeDialog()
}}
color='secondary'
autoFocus
>
{t('TurnOff')}
</UnsafeButton>
</DialogActions>
</StyledDialog>
</>
)
}
@@ -0,0 +1,76 @@
import { useTranslation } from 'react-i18next'
import { Checkbox, FormControlLabel } from '@material-ui/core'
import { useState } from 'react'
import { SectionTitle, WidgetWrapper } from '../style'
import { DetailedViewCacheSection, DetailedViewWidgetSection } from './style'
import TorrentCache from '../TorrentCache'
import {
SizeWidget,
PiecesLengthWidget,
StatusWidget,
PiecesCountWidget,
PeersWidget,
UploadSpeedWidget,
DownlodSpeedWidget,
} from '../widgets'
export default function DetailedView({
downloadSpeed,
uploadSpeed,
torrent,
torrentSize,
PiecesCount,
PiecesLength,
stat,
cache,
}) {
const { t } = useTranslation()
const [isSnakeDebugMode, setIsSnakeDebugMode] = useState(
JSON.parse(localStorage.getItem('isSnakeDebugMode')) || false,
)
return (
<>
<DetailedViewWidgetSection>
<SectionTitle mb={20}>{t('Data')}</SectionTitle>
<WidgetWrapper detailedView>
<DownlodSpeedWidget data={downloadSpeed} />
<UploadSpeedWidget data={uploadSpeed} />
<PeersWidget data={torrent} />
<SizeWidget data={torrentSize} />
<PiecesCountWidget data={PiecesCount} />
<PiecesLengthWidget data={PiecesLength} />
<StatusWidget stat={stat} />
</WidgetWrapper>
</DetailedViewWidgetSection>
<DetailedViewCacheSection>
<SectionTitle color='#000' mb={20}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{t('Cache')}</span>
<FormControlLabel
control={
<Checkbox
color='primary'
checked={isSnakeDebugMode}
disableRipple
onChange={({ target: { checked } }) => {
setIsSnakeDebugMode(checked)
localStorage.setItem('isSnakeDebugMode', checked)
}}
/>
}
label={t('DebugMode')}
labelPlacement='start'
/>
</div>
</SectionTitle>
<TorrentCache cache={cache} isSnakeDebugMode={isSnakeDebugMode} />
</DetailedViewCacheSection>
</>
)
}
@@ -0,0 +1,33 @@
import styled, { css } from 'styled-components'
export const DetailedViewWidgetSection = styled.section`
${({
theme: {
detailedView: { gradientStartColor, gradientEndColor },
},
}) => css`
padding: 40px;
background: linear-gradient(145deg, ${gradientStartColor}, ${gradientEndColor});
@media (max-width: 800px) {
padding: 20px;
}
`}
`
export const DetailedViewCacheSection = styled.section`
${({
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;
}
`}
`
@@ -0,0 +1,33 @@
import { AppBar, IconButton, makeStyles, Toolbar, Typography } from '@material-ui/core'
import CloseIcon from '@material-ui/icons/Close'
import { ArrowBack } from '@material-ui/icons'
import { isStandaloneApp } from 'utils/Utils'
const useStyles = makeStyles({
appBar: { position: 'relative', ...(isStandaloneApp && { paddingTop: '30px' }) },
title: { marginLeft: '5px', flex: 1 },
})
export default function DialogHeader({ title, onClose, onBack }) {
const classes = useStyles()
return (
<AppBar className={classes.appBar}>
<Toolbar>
{onBack && (
<IconButton edge='start' color='inherit' onClick={onBack} aria-label='back'>
<ArrowBack />
</IconButton>
)}
<Typography variant='h6' className={classes.title}>
{title}
</Typography>
<IconButton autoFocus color='inherit' onClick={onClose} aria-label='close' style={{ marginRight: '-10px' }}>
<CloseIcon />
</IconButton>
</Toolbar>
</AppBar>
)
}
@@ -0,0 +1,14 @@
import { WidgetFieldWrapper, WidgetFieldIcon, WidgetFieldValue, WidgetFieldTitle } from './style'
export default function StatisticsField({ icon: Icon, title, value, iconBg, valueBg }) {
return (
<WidgetFieldWrapper>
<WidgetFieldTitle>{title}</WidgetFieldTitle>
<WidgetFieldIcon bgColor={iconBg}>
<Icon />
</WidgetFieldIcon>
<WidgetFieldValue bgColor={valueBg}>{value}</WidgetFieldValue>
</WidgetFieldWrapper>
)
}
@@ -0,0 +1,233 @@
import { streamHost } from 'utils/Hosts'
import isEqual from 'lodash/isEqual'
import { humanizeSize, detectStandaloneApp, isMacOS, isAppleDevice } from 'utils/Utils'
import ptt from 'parse-torrent-title'
import { Button } from '@material-ui/core'
import CopyToClipboard from 'react-copy-to-clipboard'
import { useTranslation } from 'react-i18next'
import VideoPlayer from '../../VideoPlayer'
import { TableStyle, ShortTableWrapper, ShortTable } from './style'
const { memo, useState } = 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()
const [isSupported, setIsSupported] = useState(true)
const preloadBuffer = fileId => fetch(`${streamHost()}?link=${hash}&index=${fileId}&preload`)
const getFileLink = (path, id) =>
`${streamHost()}/${encodeURIComponent(path.split('\\').pop().split('/').pop())}?link=${hash}&index=${id}&play`
const fileHasEpisodeText = !!playableFileList?.find(({ path }) => ptt.parse(path).episode)
const fileHasSeasonText = !!playableFileList?.find(({ path }) => ptt.parse(path).season)
const fileHasResolutionText = !!playableFileList?.find(({ path }) => ptt.parse(path).resolution)
// 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
const isVlcUsed = JSON.parse(localStorage.getItem('isVlcUsed')) ?? false
const isInfuseUsed = JSON.parse(localStorage.getItem('isInfuseUsed')) ?? false
const isIinaUsed = JSON.parse(localStorage.getItem('isIinaUsed')) ?? false
const isStandalone = detectStandaloneApp()
const isMac = isMacOS()
const isApple = isAppleDevice()
const shouldShowOpenLink = !isStandalone || (!(isApple && isInfuseUsed) && !isVlcUsed && !(isMac && isIinaUsed))
return !playableFileList?.length ? (
'No playable files in this torrent'
) : (
<>
<TableStyle>
<thead>
<tr>
<th style={{ width: '0' }}>{t('Viewed')}</th>
<th>{t('Name')}</th>
{fileHasSeasonText && seasonAmount?.length === 1 && <th style={{ width: '0' }}>{t('Season')}</th>}
{fileHasEpisodeText && <th style={{ width: '0' }}>{t('Episode')}</th>}
{fileHasResolutionText && <th style={{ width: '0' }}>{t('Resolution')}</th>}
<th style={{ width: '100px' }}>{t('Size')}</th>
<th style={{ width: '400px' }}>{t('Actions')}</th>
</tr>
</thead>
<tbody>
{playableFileList.map(({ id, path, length }) => {
const { title, resolution, episode, season } = ptt.parse(path)
const isViewed = viewedFileList?.includes(id)
const link = getFileLink(path, id)
const fullLink = new URL(link, window.location.href)
const infuseLink = `infuse://x-callback-url/play?url=${encodeURIComponent(fullLink)}`
const iinaLink = `iina://weblink?url=${encodeURIComponent(fullLink)}`
return (
(season === selectedSeason || !seasonAmount?.length) && (
<tr key={id} className={isViewed ? 'viewed-file-row' : null}>
<td data-label='viewed' aria-label='viewed' className={isViewed ? 'viewed-file-indicator' : null} />
<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>
<div className='button-cell'>
<Button onClick={() => preloadBuffer(id)} variant='outlined' color='primary' size='small'>
{t('Preload')}
</Button>
{isApple && isInfuseUsed && (
<a style={{ textDecoration: 'none' }} href={infuseLink}>
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
{t('Infuse')}
</Button>
</a>
)}
{isVlcUsed && (
<a style={{ textDecoration: 'none' }} href={`vlc://${fullLink}`}>
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
VLC
</Button>
</a>
)}
{isMac && isIinaUsed && (
<a style={{ textDecoration: 'none' }} href={iinaLink}>
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
IINA
</Button>
</a>
)}
{isSupported ? (
<VideoPlayer title={title} videoSrc={link} onNotSupported={() => setIsSupported(false)} />
) : (
shouldShowOpenLink && (
<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={fullLink}>
<Button variant='outlined' color='primary' size='small'>
{t('CopyLink')}
</Button>
</CopyToClipboard>
{isSupported && shouldShowOpenLink && (
<a style={{ textDecoration: 'none' }} href={link} target='_blank' rel='noreferrer'>
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
{t('OpenLink')}
</Button>
</a>
)}
</div>
</td>
</tr>
)
)
})}
</tbody>
</TableStyle>
<ShortTableWrapper>
{playableFileList.map(({ id, path, length }) => {
const { title, resolution, episode, season } = ptt.parse(path)
const isViewed = viewedFileList?.includes(id)
const link = getFileLink(path, id)
const fullLink = new URL(link, window.location.href)
const infuseLink = `infuse://x-callback-url/play?url=${encodeURIComponent(fullLink)}`
const iinaLink = `iina://weblink?url=${encodeURIComponent(fullLink)}`
return (
(season === selectedSeason || !seasonAmount?.length) && (
<ShortTable key={id} isViewed={isViewed}>
<div className='short-table-name'>{shouldDisplayFullFileName ? path : title}</div>
<div className='short-table-data'>
{isViewed && (
<div className='short-table-field'>
<div className='short-table-field-name'>{t('Viewed')}</div>
<div className='short-table-field-value'>
<div className='short-table-viewed-indicator' />
</div>
</div>
)}
{fileHasSeasonText && seasonAmount?.length === 1 && (
<div className='short-table-field'>
<div className='short-table-field-name'>{t('Season')}</div>
<div className='short-table-field-value'>{season}</div>
</div>
)}
{fileHasEpisodeText && (
<div className='short-table-field'>
<div className='short-table-field-name'>{t('Episode')}</div>
<div className='short-table-field-value'>{episode}</div>
</div>
)}
{fileHasResolutionText && (
<div className='short-table-field'>
<div className='short-table-field-name'>{t('Resolution')}</div>
<div className='short-table-field-value'>{resolution}</div>
</div>
)}
<div className='short-table-field'>
<div className='short-table-field-name'>{t('Size')}</div>
<div className='short-table-field-value'>{humanizeSize(length)}</div>
</div>
</div>
<div className='short-table-buttons'>
<Button onClick={() => preloadBuffer(id)} variant='outlined' color='primary' size='small'>
{t('Preload')}
</Button>
{isApple && isInfuseUsed && (
<a style={{ textDecoration: 'none' }} href={infuseLink}>
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
{t('Infuse')}
</Button>
</a>
)}
{isVlcUsed && (
<a style={{ textDecoration: 'none' }} href={`vlc://${fullLink}`}>
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
VLC
</Button>
</a>
)}
{isMac && isIinaUsed && (
<a style={{ textDecoration: 'none' }} href={iinaLink}>
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
IINA
</Button>
</a>
)}
{shouldShowOpenLink && (
<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={fullLink}>
<Button variant='outlined' color='primary' size='small'>
{t('CopyLink')}
</Button>
</CopyToClipboard>
</div>
</ShortTable>
)
)
})}
</ShortTableWrapper>
</>
)
},
(prev, next) => isEqual(prev, next),
)
export default Table
@@ -0,0 +1,199 @@
import styled, { css } from 'styled-components'
const viewedPrimaryColor = '#858c90'
const viewedSecondaryColor = '#8c9498'
const viewedTertiaryColor = '#949ca0'
const bigTableDividerColor = '#d2d2d2'
const bigTableDefaultRowColor = '#f3f3f3'
const bigTableViewedRowColor = '#ddd'
const viewedIndicator = css`
${({
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`
${({
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: ${defaultPrimaryColor};
color: #fff;
text-align: left;
text-transform: uppercase;
}
th,
td {
padding: 12px 15px;
}
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(auto-fit, minmax(100px, 1fr));
gap: 10px;
}
@media (max-width: 970px) {
display: none;
}
`}
`
export const ShortTableWrapper = styled.div`
display: grid;
gap: 20px;
grid-template-columns: repeat(2, 1fr);
display: none;
@media (max-width: 970px) {
display: grid;
}
@media (max-width: 820px) {
gap: 15px;
grid-template-columns: 1fr;
}
`
export const ShortTable = styled.div`
${({
isViewed,
theme: {
table: { defaultPrimaryColor, defaultSecondaryColor, defaultTertiaryColor },
},
}) => css`
width: 100%;
grid-template-rows: repeat(3, max-content);
border-radius: 5px;
overflow: hidden;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
.short-table {
&-name {
background: ${isViewed ? viewedPrimaryColor : defaultPrimaryColor};
display: grid;
place-items: center;
padding: 15px;
color: #fff;
text-transform: uppercase;
font-size: 15px;
font-weight: bold;
@media (max-width: 880px) {
font-size: 13px;
padding: 10px;
}
}
&-data {
display: grid;
grid-auto-flow: column;
grid-template-columns: ${isViewed ? 'max-content' : '1fr'};
grid-auto-columns: 1fr;
}
&-field {
display: grid;
grid-template-rows: 30px 1fr;
background: black;
:not(:last-child) {
border-right: 1px solid ${isViewed ? viewedPrimaryColor : defaultPrimaryColor};
}
&-name {
background: ${isViewed ? viewedSecondaryColor : defaultSecondaryColor};
color: #fff;
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
display: grid;
place-items: center;
padding: 0 10px;
@media (max-width: 880px) {
font-size: 11px;
}
}
&-value {
background: ${isViewed ? viewedTertiaryColor : defaultTertiaryColor};
display: grid;
place-items: center;
color: #fff;
font-size: 15px;
padding: 15px 10px;
position: relative;
@media (max-width: 880px) {
font-size: 13px;
padding: 12px 8px;
}
}
}
&-viewed-indicator {
${isViewed && viewedIndicator}
}
&-buttons {
padding: 20px;
border-bottom: 2px solid ${isViewed ? viewedPrimaryColor : defaultPrimaryColor};
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
align-items: center;
gap: 20px;
background: #f3f3f3;
@media (max-width: 410px) {
gap: 10px;
grid-template-columns: 1fr;
}
}
}
`}
`
@@ -0,0 +1,27 @@
export default ({ cacheMap, preloadPiecesAmount, piecesInOneRow }) => {
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
amountOfBlocks % piecesInOneRow === 0
? amountOfBlocks - 1
: amountOfBlocks + piecesInOneRow - (amountOfBlocks % piecesInOneRow) - 1 || 0
const amountOfBlocksToRenderInShortView = getFullAmountOfBlocks(preloadPiecesAmount)
// preloadPiecesAmount is counted from "cache.Capacity / cache.PiecesLength". We always show at least this amount of blocks
const scalableAmountOfBlocksToRenderInShortView = getFullAmountOfBlocks(cacheMapWithoutEmptyBlocks.length)
// cacheMap can become bigger than preloadPiecesAmount counted before. In that case we count blocks dynamically
const finalAmountOfBlocksToRenderInShortView = Math.max(
// this check is needed to decide which is the biggest amount of blocks and take it to render
scalableAmountOfBlocksToRenderInShortView,
amountOfBlocksToRenderInShortView,
)
const extraBlocksAmount = finalAmountOfBlocksToRenderInShortView - cacheMapWithoutEmptyBlocks.length + 1
// amount of blocks needed to fill the line till the end
const extraEmptyBlocksForFillingLine = extraBlocksAmount ? new Array(extraBlocksAmount).fill({}) : []
return [...cacheMapWithoutEmptyBlocks, ...extraEmptyBlocksForFillingLine]
}
@@ -0,0 +1,147 @@
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 getShortCacheMap from './getShortCacheMap'
import { SnakeWrapper, ScrollNotification } from './style'
import { createGradient, snakeSettings } from './snakeSettings'
const TorrentCache = ({ cache, isMini, isSnakeDebugMode }) => {
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)
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
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, priority, 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)
if (isSnakeDebugMode && priority > 0) {
let info = ''
if (priority === 1) info = ''
else if (priority === 2) info = 'H'
else if (priority === 3) info = 'R'
else if (priority === 4) info = 'N'
else if (priority === 5) info = 'A'
ctx.font = isMini ? '13px monospace' : '10px monospace'
const xpad = isMini ? pieceSize * 0.35 : pieceSize * 0.29
const ypad = isMini ? pieceSize * 0.69 : pieceSize * 0.78
ctx.fillStyle = 'black'
ctx.fillText(info, x + xpad, y + ypad)
}
})
}, [
cacheMap,
height,
canvasWidth,
piecesInOneRow,
startingXPoint,
pieceSize,
gapBetweenPieces,
source,
backgroundColor,
borderColor,
borderWidth,
settingsTarget,
completeColor,
readerColor,
rangeColor,
isMini,
theme,
isSnakeDebugMode,
])
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),
)
@@ -0,0 +1,67 @@
import { rgba } from 'polished'
import { mainColors } from 'style/colors'
export const snakeSettings = {
dark: {
default: {
borderWidth: 1,
pieceSize: 14,
gapBetweenPieces: 3,
borderColor: rgba('#fff', 0.2),
completeColor: rgba(mainColors.dark.primary, 0.5),
backgroundColor: '#949ca0',
progressColor: rgba('#fff', 0.2),
readerColor: '#8f0405',
rangeColor: '#cda184',
},
mini: {
cacheMaxHeight: 340,
borderWidth: 2,
pieceSize: 23,
gapBetweenPieces: 6,
borderColor: '#5c6469',
completeColor: '#5c6469',
backgroundColor: '#949ca0',
progressColor: '#949ca0',
readerColor: '#ccc',
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: '#0a0a0a',
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;
}
`}
`
@@ -0,0 +1,89 @@
import axios from 'axios'
import { memo } from 'react'
import { playlistTorrHost, torrentsHost, viewedHost } from 'utils/Hosts'
import { CopyToClipboard } from 'react-copy-to-clipboard'
import { Button } from '@material-ui/core'
import ptt from 'parse-torrent-title'
import { useTranslation } from 'react-i18next'
import { SmallLabel, MainSectionButtonGroup } from './style'
import { SectionSubName } from '../style'
const TorrentFunctions = memo(
({ hash, viewedFileList, playableFileList, name, title, setViewedFileList }) => {
const { t } = useTranslation()
const latestViewedFileId = viewedFileList?.[viewedFileList?.length - 1]
const latestViewedFile = playableFileList?.find(({ id }) => id === latestViewedFileId)?.path
const isOnlyOnePlayableFile = playableFileList?.length === 1
const latestViewedFileData = latestViewedFile && ptt.parse(latestViewedFile)
const dropTorrent = () => axios.post(torrentsHost(), { action: 'drop', hash })
const removeTorrentViews = () =>
axios.post(viewedHost(), { action: 'rem', hash, file_index: -1 }).then(() => setViewedFileList())
const fullPlaylistLink = `${playlistTorrHost()}/${encodeURIComponent(name || title || 'file')}.m3u?link=${hash}&m3u`
const partialPlaylistLink = `${fullPlaylistLink}&fromlast`
const magnet = `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(name || title)}`
return (
<>
{!isOnlyOnePlayableFile && !!viewedFileList?.length && (
<>
<SmallLabel>{t('DownloadPlaylist')}</SmallLabel>
<SectionSubName mb={10}>
{t('LatestFilePlayed')}{' '}
<strong>
{latestViewedFileData?.title}.
{latestViewedFileData?.season && (
<>
{' '}
{t('Season')}: {latestViewedFileData?.season}. {t('Episode')}: {latestViewedFileData?.episode}.
</>
)}
</strong>
</SectionSubName>
<MainSectionButtonGroup>
<a style={{ textDecoration: 'none' }} href={fullPlaylistLink}>
<Button style={{ width: '100%' }} variant='contained' color='primary' size='large'>
{t('Full')}
</Button>
</a>
<a style={{ textDecoration: 'none' }} href={partialPlaylistLink}>
<Button style={{ width: '100%' }} variant='contained' color='primary' size='large'>
{t('FromLatestFile')}
</Button>
</a>
</MainSectionButtonGroup>
</>
)}
<SmallLabel mb={10}>{t('TorrentState')}</SmallLabel>
<MainSectionButtonGroup>
<Button onClick={() => removeTorrentViews()} variant='contained' color='primary' size='large'>
{t('RemoveViews')}
</Button>
<Button onClick={() => dropTorrent()} variant='contained' color='primary' size='large'>
{t('DropTorrent')}
</Button>
</MainSectionButtonGroup>
<SmallLabel mb={10}>{t('Info')}</SmallLabel>
<MainSectionButtonGroup>
{(isOnlyOnePlayableFile || !viewedFileList?.length) && (
<a style={{ textDecoration: 'none' }} href={fullPlaylistLink}>
<Button style={{ width: '100%' }} variant='contained' color='primary' size='large'>
{t('DownloadPlaylist')}
</Button>
</a>
)}
<CopyToClipboard text={magnet}>
<Button variant='contained' color='primary' size='large'>
{t('CopyHash')}
</Button>
</CopyToClipboard>
</MainSectionButtonGroup>
</>
)
},
() => true,
)
export default TorrentFunctions
@@ -0,0 +1,39 @@
import styled, { css } from 'styled-components'
export const MainSectionButtonGroup = styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
:not(:last-child) {
margin-bottom: 30px;
}
@media (max-width: 1580px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 880px) {
grid-template-columns: 1fr;
}
`
export const SmallLabel = styled.div`
${({
mb,
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;
${mb && `margin-bottom: ${mb / 1.5}px`};
}
`}
`
@@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from 'react'
import { cacheHost, settingsHost } from 'utils/Hosts'
import axios from 'axios'
export const useUpdateCache = hash => {
const [cache, setCache] = useState({})
const componentIsMounted = useRef(true)
const timerID = useRef(null)
useEffect(
() => () => {
// this function is required to notify "updateCache" when NOT to make state update
componentIsMounted.current = false
},
[],
)
useEffect(() => {
if (hash) {
timerID.current = setInterval(() => {
const updateCache = newCache => componentIsMounted.current && setCache(newCache)
axios
.post(cacheHost(), { action: 'get', hash })
.then(({ data }) => updateCache(data))
// empty cache if error
.catch(() => updateCache({}))
}, 100)
} else clearInterval(timerID.current)
return () => clearInterval(timerID.current)
}, [hash])
return cache
}
export const useCreateCacheMap = cache => {
const [cacheMap, setCacheMap] = useState([])
useEffect(() => {
const { PiecesCount, Pieces, Readers } = cache
const map = []
for (let i = 0; i < PiecesCount; i++) {
const { Size, Length, Priority } = Pieces[i] || {}
const newPiece = { id: i, percentage: (Size / Length) * 100 || 0, priority: Priority || 0 }
Readers.forEach(r => {
if (i === r.Reader) newPiece.isReader = true
if (i >= r.Start && i < r.End) newPiece.isReaderRange = true
})
map.push(newPiece)
}
setCacheMap(map)
}, [cache])
return cacheMap
}
export const useGetSettings = cache => {
const [settings, setSettings] = useState()
useEffect(() => {
axios.post(settingsHost(), { action: 'get' }).then(({ data }) => setSettings(data))
}, [cache])
return settings
}
@@ -0,0 +1,78 @@
const getExt = filename => {
const ext = filename.split('.').pop()
if (ext === filename) return ''
return ext.toLowerCase()
}
const playableExtList = [
// video
'3g2',
'3gp',
'aaf',
'asf',
'avchd',
'avi',
'drc',
'dv',
'flv',
'iso',
'm2ts',
'm2v',
'm4p',
'm4v',
'mkv',
'mng',
'mov',
'mp2',
'mp4',
'mpe',
'mpeg',
'mpg',
'mpv',
'mts',
'mxf',
'nsv',
'ogv',
'qt',
'rm',
'rmvb',
'roq',
'svi',
'ts',
'vob',
'webm',
'wmv',
'yuv',
// audio
'aac',
'ac3',
'aiff',
'ape',
'au',
'dff',
'dsf',
'flac',
'gsm',
'it',
'm3u',
'm4a',
'mid',
'mod',
'mp3',
'mpa',
'oga',
'ogg',
'opus',
'pls',
'ra',
's3m',
'sid',
'wav',
'weba',
'wma',
'wv',
'wvc',
'xm',
]
// eslint-disable-next-line import/prefer-default-export
export const isFilePlayable = fileName => playableExtList.includes(getExt(fileName))
@@ -0,0 +1,265 @@
import { NoImageIcon } from 'icons'
import { humanizeSize, removeRedundantCharacters } from 'utils/Utils'
import { useEffect, useState } from 'react'
import { Button, ButtonGroup } from '@material-ui/core'
import ptt from 'parse-torrent-title'
import axios from 'axios'
import { viewedHost } from 'utils/Hosts'
import { GETTING_INFO, IN_DB } from 'torrentStates'
import CircularProgress from '@material-ui/core/CircularProgress'
import { useTranslation } from 'react-i18next'
import { useUpdateCache, useGetSettings } from './customHooks'
import DialogHeader from './DialogHeader'
import TorrentCache from './TorrentCache'
import Table from './Table'
import DetailedView from './DetailedView'
import {
DialogContentGrid,
MainSection,
Poster,
SectionTitle,
SectionSubName,
WidgetWrapper,
LoadingProgress,
SectionHeader,
CacheSection,
TorrentFilesSection,
Divider,
} from './style'
import { DownlodSpeedWidget, UploadSpeedWidget, PeersWidget, SizeWidget, StatusWidget, CategoryWidget } from './widgets'
import TorrentFunctions from './TorrentFunctions'
import { isFilePlayable } from './helpers'
const Loader = () => (
<div style={{ minHeight: '80vh', display: 'grid', placeItems: 'center' }}>
<CircularProgress color='secondary' />
</div>
)
export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true)
const [isDetailedCacheView, setIsDetailedCacheView] = useState(false)
const [viewedFileList, setViewedFileList] = useState()
const [playableFileList, setPlayableFileList] = useState()
const [seasonAmount, setSeasonAmount] = useState(null)
const [selectedSeason, setSelectedSeason] = useState()
const [isSnakeDebugMode] = useState(JSON.parse(localStorage.getItem('isSnakeDebugMode')) || false)
const {
poster,
hash,
title,
category,
name,
stat,
download_speed: downloadSpeed,
upload_speed: uploadSpeed,
torrent_size: torrentSize,
file_stats: torrentFileList,
} = torrent
const cache = useUpdateCache(hash)
const settings = useGetSettings(cache)
const { Capacity, PiecesCount, PiecesLength, Filled } = cache
useEffect(() => {
if (playableFileList && seasonAmount === null) {
const seasons = []
playableFileList.forEach(({ path }) => {
const currentSeason = ptt.parse(path).season
if (currentSeason) {
!seasons.includes(currentSeason) && seasons.push(currentSeason)
}
})
seasons.length && setSelectedSeason(seasons[0])
setSeasonAmount(seasons.sort((a, b) => a - b))
}
}, [playableFileList, seasonAmount])
useEffect(() => {
setPlayableFileList(torrentFileList?.filter(({ path }) => isFilePlayable(path)))
}, [torrentFileList])
useEffect(() => {
const cacheLoaded = !!Object.entries(cache).length
const torrentLoaded = stat !== GETTING_INFO && stat !== IN_DB
if (!cacheLoaded && !isLoading) setIsLoading(true)
if (cacheLoaded && isLoading && torrentLoaded) setIsLoading(false)
}, [stat, cache, isLoading])
useEffect(() => {
// getting viewed file list
axios.post(viewedHost(), { action: 'list', hash }).then(({ data }) => {
if (data) {
const lst = data.map(itm => itm.file_index).sort((a, b) => a - b)
setViewedFileList(lst)
} else setViewedFileList()
})
}, [hash])
const preloadPerc = settings?.PreloadCache
const preloadSize = (Capacity / 100) * preloadPerc
const bufferSize = preloadSize > 33554432 ? preloadSize : 33554432 // Not less than 32MB
const getParsedTitle = () => {
const newNameStringArr = []
const torrentParsedName = name && ptt.parse(name)
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.header') : t('TorrentDetails')}
{...(isDetailedCacheView && { onBack: () => setIsDetailedCacheView(false) })}
/>
<div
style={{
minHeight: '80vh',
overflow: 'auto',
...(isDetailedCacheView && { display: 'flex', flexDirection: 'column' }),
}}
>
{isLoading ? (
<Loader />
) : isDetailedCacheView ? (
<DetailedView
downloadSpeed={downloadSpeed}
uploadSpeed={uploadSpeed}
torrent={torrent}
torrentSize={torrentSize}
PiecesCount={PiecesCount}
PiecesLength={PiecesLength}
stat={stat}
cache={cache}
/>
) : (
<DialogContentGrid>
<MainSection>
<Poster poster={poster}>{poster ? <img alt='poster' src={poster} /> : <NoImageIcon />}</Poster>
<div>
{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}>{getParsedTitle()}</SectionTitle>
)}
<WidgetWrapper>
<DownlodSpeedWidget data={downloadSpeed} />
<UploadSpeedWidget data={uploadSpeed} />
<PeersWidget data={torrent} />
<SizeWidget data={torrentSize} />
<StatusWidget stat={stat} />
<CategoryWidget data={category} />
</WidgetWrapper>
<Divider />
<TorrentFunctions
hash={hash}
viewedFileList={viewedFileList}
playableFileList={playableFileList}
name={name}
title={title}
setViewedFileList={setViewedFileList}
/>
</div>
</MainSection>
<CacheSection>
<SectionHeader>
<SectionTitle mb={20}>{t('Buffer')}</SectionTitle>
{bufferSize <= 33554432 && <SectionSubName>{t('BufferNote')}</SectionSubName>}
<LoadingProgress
value={Filled}
style={{ marginTop: '5px' }}
fullAmount={bufferSize}
label={`${humanizeSize(bufferSize)} / ${humanizeSize(Filled) || `0 ${t('B')}`}`}
/>
</SectionHeader>
<TorrentCache isMini cache={cache} isSnakeDebugMode={isSnakeDebugMode} />
<Button
style={{ marginTop: '30px' }}
variant='contained'
color='primary'
size='large'
onClick={() => setIsDetailedCacheView(true)}
>
{t('DetailedCacheView.button')}
</Button>
</CacheSection>
<TorrentFilesSection>
<SectionTitle mb={20}>{t('TorrentContent')}</SectionTitle>
{seasonAmount?.length > 1 && (
<>
<SectionSubName mb={7}>{t('SelectSeason')}</SectionSubName>
<ButtonGroup style={{ marginBottom: '30px' }} color='secondary'>
{seasonAmount.map(season => (
<Button
key={season}
variant={selectedSeason === season ? 'contained' : 'outlined'}
onClick={() => setSelectedSeason(season)}
>
{season}
</Button>
))}
</ButtonGroup>
<SectionTitle mb={20}>
{t('Season')} {selectedSeason}
</SectionTitle>
</>
)}
<Table
hash={hash}
playableFileList={playableFileList}
viewedFileList={viewedFileList}
selectedSeason={selectedSeason}
seasonAmount={seasonAmount}
/>
</TorrentFilesSection>
</DialogContentGrid>
)}
</div>
</>
)
}
@@ -0,0 +1,316 @@
import { rgba } from 'polished'
import styled, { css } from 'styled-components'
export const DialogContentGrid = styled.div`
display: grid;
grid-template-columns: 70% 1fr;
grid-template-rows: repeat(2, min-content);
grid-template-areas:
'main cache'
'file-list file-list';
@media (max-width: 1450px) {
grid-template-columns: 1fr;
grid-template-rows: repeat(3, min-content);
grid-template-areas:
'main'
'cache'
'file-list';
}
`
export const Poster = styled.div`
${({
poster,
theme: {
dialogTorrentDetailsContent: { posterBGColor },
},
}) => css`
height: 400px;
border-radius: 5px;
overflow: hidden;
align-self: center;
${poster
? css`
img {
border-radius: 5px;
height: 100%;
}
`
: css`
width: 300px;
display: grid;
place-items: center;
background: ${posterBGColor};
svg {
transform: scale(2.5) translateY(-3px);
}
`}
@media (max-width: 1280px) {
align-self: start;
}
@media (max-width: 840px) {
${poster
? css`
height: 200px;
`
: css`
display: none;
`}
}
`}
`
export const MainSection = styled.section`
${({
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`
${({
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) {
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-top: ${mb / 3}px`};
${mb && `margin-bottom: ${mb}px`};
line-height: 1.2;
color: ${subNameFontColor};
@media (max-width: 800px) {
${mb && `margin-top: ${mb / 4}px`};
${mb && `margin-bottom: ${mb / 2}px`};
font-size: 14px;
}
`}
`}
`
export const SectionTitle = styled.div`
${({
color,
theme: {
dialogTorrentDetailsContent: { titleFontColor },
},
}) => css`
${({ mb }) => css`
${mb && `margin-bottom: ${mb}px`};
font-size: 34px;
font-weight: 300;
line-height: 1;
word-break: break-word;
color: ${color || titleFontColor};
@media (max-width: 800px) {
font-size: 24px;
line-height: 1.1;
${mb && `margin-bottom: ${mb / 2}px`};
}
`}
`}
`
export const SectionHeader = styled.div`
margin-bottom: 20px;
`
export const WidgetWrapper = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(max-content, 220px));
gap: 20px;
@media (max-width: 800px) {
gap: 15px;
}
@media (max-width: 410px) {
gap: 10px;
}
${({ detailedView }) =>
detailedView
? css`
@media (max-width: 800px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 410px) {
grid-template-columns: 1fr;
}
`
: css`
@media (max-width: 800px) {
grid-template-columns: repeat(auto-fit, minmax(max-content, 185px));
}
@media (max-width: 480px) {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 390px) {
grid-template-columns: 1fr;
}
`}
`
export const WidgetFieldWrapper = styled.div`
display: grid;
grid-template-columns: 40px 1fr;
grid-template-rows: min-content 50px;
grid-template-areas:
'title title'
'icon value';
> * {
display: grid;
place-items: center;
}
@media (max-width: 800px) {
grid-template-columns: 30px 1fr;
grid-template-rows: min-content 40px;
}
`
export const WidgetFieldTitle = styled.div`
${({
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('#fff', 0.8)};
background: ${bgColor};
border-radius: 5px 0 0 5px;
@media (max-width: 800px) {
> svg {
width: 50%;
}
}
`}
`
export const WidgetFieldValue = styled.div`
${({
bgColor,
theme: {
dialogTorrentDetailsContent: { widgetFontColor },
},
}) => css`
grid-area: value;
font-size: 24px;
padding: 0 20px 0 0;
color: ${widgetFontColor};
background: ${bgColor};
border-radius: 0 5px 5px 0;
white-space: nowrap;
@media (max-width: 800px) {
font-size: 18px;
padding: 0 16px 0 0;
}
`}
`
export const LoadingProgress = styled.div.attrs(
({
value,
fullAmount,
theme: {
dialogTorrentDetailsContent: { gradientStartColor, 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, ${gradientStartColor} 0%, ${gradientEndColor} ${percentage}%, #eee ${percentage}%, #fff 100%)`,
},
}
},
)`
${({ label }) => css`
border: 1px solid;
padding: 10px 20px;
border-radius: 5px;
color: #000;
:before {
content: '${label}';
display: grid;
place-items: center;
font-size: 20px;
}
`}
`
export const Divider = styled.div`
height: 1px;
background-color: rgba(0, 0, 0, 0.12);
margin: 30px 0;
`
@@ -0,0 +1,152 @@
import {
ArrowDownward as ArrowDownwardIcon,
ArrowUpward as ArrowUpwardIcon,
SwapVerticalCircle as SwapVerticalCircleIcon,
ViewAgenda as ViewAgendaIcon,
Widgets as WidgetsIcon,
PhotoSizeSelectSmall as PhotoSizeSelectSmallIcon,
Build as BuildIcon,
Category as CategoryIcon,
} 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 { TORRENT_CATEGORIES } from 'components/categories'
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}
/>
)
}
export const CategoryWidget = ({ data }) => {
const { t } = useTranslation()
const { iconBGColor, valueBGColor } = useGetWidgetColors('category')
// main categories
const catIndex = TORRENT_CATEGORIES.findIndex(e => e.key === data)
const catArray = TORRENT_CATEGORIES.find(e => e.key === data)
if (data) {
return (
<StatisticsField
title={t('Category')}
value={catIndex >= 0 ? t(catArray.name) : data.length > 1 ? data.charAt(0).toUpperCase() + data.slice(1) : data}
iconBg={iconBGColor}
valueBg={valueBGColor}
icon={CategoryIcon}
/>
)
}
return null
}
@@ -0,0 +1,35 @@
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' },
category: { iconBGColor: '#914820', valueBGColor: '#c9632c' },
},
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' },
category: { iconBGColor: '#914820', valueBGColor: '#c9632c' },
},
}
export default function useGetWidgetColors(widgetName) {
const { isDarkMode } = useContext(DarkModeContext)
const widgetColors = colors[isDarkMode ? DARK : LIGHT][widgetName]
return widgetColors
}
@@ -0,0 +1,45 @@
// import ListItem from '@material-ui/core/ListItem'
import DialogTitle from '@material-ui/core/DialogTitle'
import DialogContent from '@material-ui/core/DialogContent'
import DialogActions from '@material-ui/core/DialogActions'
// import List from '@material-ui/core/List'
import ButtonGroup from '@material-ui/core/ButtonGroup'
import Button from '@material-ui/core/Button'
import { useTranslation } from 'react-i18next'
import { StyledDialog } from 'style/CustomMaterialUiStyles'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
// const donateFrame = '<iframe src="https://yoomoney.ru/quickpay/shop-widget?writer=seller&targets=TorrServer Donate&targets-hint=&default-sum=200&button-text=14&payment-type-choice=on&mobile-payment-type-choice=on&comment=on&hint=&successURL=&quickpay=shop&account=410013733697114" width="320" height="320" frameborder="0" allowtransparency="true" scrolling="no"></iframe>'
export default function DonateDialog({ onClose }) {
const { t } = useTranslation()
const ref = useOnStandaloneAppOutsideClick(onClose)
return (
<StyledDialog open onClose={onClose} aria-labelledby='form-dialog-title' fullWidth maxWidth='xs' ref={ref}>
<DialogTitle id='form-dialog-title'>{t('Donate')}</DialogTitle>
<DialogContent>
{/* <List> */}
{/* <ListItem key='DonateLinks'> */}
<ButtonGroup variant='outlined' color='secondary' aria-label='contained primary button group'>
<Button onClick={() => window.open('https://boosty.to/yourok', '_blank')}>Boosty</Button>
<Button onClick={() => window.open('https://yoomoney.ru/to/410013733697114', '_blank')}>IO.Money</Button>
<Button onClick={() => window.open('https://www.tbank.ru/cf/742qEMhKhKn', '_blank')}>TBank</Button>
{/* <Button onClick={() => window.open('https://qiwi.com/n/YOUROK85', '_blank')}>QIWI</Button> */}
{/* <Button onClick={() => window.open('https://www.paypal.com/paypalme/yourok', '_blank')}>PayPal</Button> */}
</ButtonGroup>
{/* </ListItem> */}
{/* <ListItem key='DonateForm'> */}
{/* eslint-disable-next-line react/no-danger */}
{/* <div dangerouslySetInnerHTML={{ __html: donateFrame }} /> */}
{/* </ListItem> */}
{/* </List> */}
</DialogContent>
<DialogActions>
<Button onClick={onClose} color='secondary' variant='contained'>
Ok
</Button>
</DialogActions>
</StyledDialog>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react'
import Button from '@material-ui/core/Button'
import Snackbar from '@material-ui/core/Snackbar'
import IconButton from '@material-ui/core/IconButton'
import CreditCardIcon from '@material-ui/icons/CreditCard'
import CloseIcon from '@material-ui/icons/Close'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { standaloneMedia } from 'style/standaloneMedia'
import DonateDialog from './DonateDialog'
const StyledSnackbar = styled(Snackbar)`
${standaloneMedia('margin-bottom: 90px')};
`
export default function DonateSnackbar() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [snackbarOpen, setSnackbarOpen] = useState(true)
const disableSnackbar = () => {
setSnackbarOpen(false)
localStorage.setItem('snackbarIsClosed', true)
}
return (
<>
{open && <DonateDialog onClose={() => setOpen(false)} />}
<StyledSnackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
open={snackbarOpen}
onClose={disableSnackbar}
message={t('Donate?')}
action={
<>
<Button
style={{ marginRight: '10px' }}
color='secondary'
size='small'
onClick={() => {
setOpen(true)
disableSnackbar()
}}
>
<CreditCardIcon style={{ marginRight: '10px' }} fontSize='small' />
{t('Support')}
</Button>
<IconButton size='small' aria-label='close' color='inherit' onClick={disableSnackbar}>
<CloseIcon fontSize='small' />
</IconButton>
</>
}
/>
</>
)
}
+20
View File
@@ -0,0 +1,20 @@
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'
export default function FilterByCategory({ categoryKey, categoryName, setGlobalFilterCategory, icon }) {
const onClick = () => {
setGlobalFilterCategory(categoryKey)
}
const { t } = useTranslation()
return (
<>
<ListItem button key={categoryKey} onClick={onClick}>
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={t(categoryName)} />
</ListItem>
</>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { Button, Dialog, DialogActions, DialogTitle } from '@material-ui/core'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import DeleteIcon from '@material-ui/icons/Delete'
import { useState } from 'react'
import { torrentsHost } from 'utils/Hosts'
import { useTranslation } from 'react-i18next'
import UnsafeButton from './UnsafeButton'
const fnRemoveAll = () => {
fetch(torrentsHost(), {
method: 'post',
body: JSON.stringify({ action: 'wipe' }),
headers: {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
},
})
}
export default function RemoveAll({ isOffline, isLoading }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const closeDialog = () => setOpen(false)
const openDialog = () => setOpen(true)
return (
<>
<ListItem disabled={isOffline || isLoading} button key={t('RemoveAll')} onClick={openDialog}>
<ListItemIcon>
<DeleteIcon />
</ListItemIcon>
<ListItemText primary={t('RemoveAll')} />
</ListItem>
<Dialog open={open} onClose={closeDialog}>
<DialogTitle>{t('DeleteTorrents?')}</DialogTitle>
<DialogActions>
<Button variant='outlined' onClick={closeDialog} color='secondary'>
{t('Cancel')}
</Button>
<UnsafeButton
timeout={5}
startIcon={<DeleteIcon />}
variant='contained'
onClick={() => {
fnRemoveAll()
closeDialog()
}}
color='secondary'
autoFocus
>
{t('OK')}
</UnsafeButton>
</DialogActions>
</Dialog>
</>
)
}
+347
View File
@@ -0,0 +1,347 @@
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import {
TextField,
Button,
List,
ListItem,
ListItemText,
CircularProgress,
Typography,
Divider,
ListItemSecondaryAction,
IconButton,
Snackbar,
useMediaQuery,
Select,
MenuItem,
FormControl,
InputLabel,
} from '@material-ui/core'
import { CloudDownload as DownloadIcon, ArrowUpward, ArrowDownward } from '@material-ui/icons'
import { torznabSearchHost, torrentsHost, settingsHost, searchHost } from 'utils/Hosts'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
import { StyledDialog, StyledHeader } from 'style/CustomMaterialUiStyles'
import { parseSizeToBytes, formatSizeToClassicUnits } from 'utils/Utils'
import { getMoviePosters, shortenTitleForPosterSearch } from 'components/Add/helpers'
import { Content } from './style'
export default function SearchDialog({ handleClose }) {
const { t } = useTranslation()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [adding, setAdding] = useState(false)
const [successMsg, setSuccessMsg] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [trackers, setTrackers] = useState([])
const [enableRutor, setEnableRutor] = useState(false)
const [selectedTracker, setSelectedTracker] = useState(-1)
const [sortField, setSortField] = useState('') // '', 'size', 'seeds', 'peers'
const [sortDirection, setSortDirection] = useState('desc') // 'asc' or 'desc'
const fullScreen = useMediaQuery('@media (max-width:930px)')
const isMobile = useMediaQuery('(max-width:600px)')
const ref = useOnStandaloneAppOutsideClick(handleClose)
useEffect(() => {
axios
.post(settingsHost(), { action: 'get' })
.then(({ data }) => {
if (data) {
if (data.TorznabUrls) {
setTrackers(data.TorznabUrls)
}
setEnableRutor(!!data.EnableRutorSearch)
}
})
.catch(() => {})
}, [])
const handleSearch = async () => {
if (!query) return
setLoading(true)
setSearched(true)
setResults([])
try {
let url = torznabSearchHost()
const params = { query }
if (selectedTracker === 'rutor') {
url = searchHost()
} else if (selectedTracker !== -1) {
params.index = selectedTracker
}
const { data } = await axios.get(url, { params })
setResults(data || [])
} catch (error) {
setErrorMsg(t('Torznab.SearchFailed'))
} finally {
setLoading(false)
}
}
const handleKeyDown = e => {
if (e.key === 'Enter') {
handleSearch()
}
}
const handleAdd = async item => {
setAdding(true)
try {
const link = item.Magnet || item.Link
if (!link) {
setErrorMsg(t('Torznab.NoLinkFound'))
return
}
let poster = item.Poster
if (!poster && item.Title) {
const query = shortenTitleForPosterSearch(item.Title)
if (query) {
const urlList = await getMoviePosters(query, 'en')
const [firstPosterUrl] = urlList || []
if (firstPosterUrl) poster = firstPosterUrl
}
}
await axios.post(torrentsHost(), {
action: 'add',
link,
title: item.Title,
save_to_db: true,
poster: poster || '',
})
setSuccessMsg(t('Torznab.TorrentAddedSuccessfully'))
} catch (error) {
setErrorMsg(t('Torznab.FailedToAddTorrent'))
} finally {
setAdding(false)
}
}
const handleAlertClose = (event, reason) => {
if (reason === 'clickaway') {
return
}
setSuccessMsg('')
setErrorMsg('')
}
const toggleSortDirection = () => {
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'))
}
const sortedResults = useMemo(() => {
if (!sortField || results.length === 0) return results
const sorted = [...results].sort((a, b) => {
let aVal
let bVal
switch (sortField) {
case 'size':
aVal = parseSizeToBytes(a.Size || '0')
bVal = parseSizeToBytes(b.Size || '0')
break
case 'seeds':
aVal = a.Seed || 0
bVal = b.Seed || 0
break
case 'peers':
aVal = a.Peer || 0
bVal = b.Peer || 0
break
default:
return 0
}
if (aVal === bVal) return 0
return sortDirection === 'asc' ? (aVal < bVal ? -1 : 1) : aVal > bVal ? -1 : 1
})
return sorted
}, [results, sortField, sortDirection])
return (
<StyledDialog open onClose={handleClose} fullScreen={fullScreen} fullWidth maxWidth='md' ref={ref}>
<StyledHeader>{t('Torznab.SearchTorrents')}</StyledHeader>
<Content>
<div style={{ padding: '20px' }}>
<div
style={{
display: 'flex',
gap: '8px',
marginBottom: '20px',
alignItems: 'flex-start',
flexWrap: fullScreen ? 'wrap' : 'nowrap',
}}
>
<FormControl
variant='outlined'
size='small'
style={{
minWidth: 150,
flex: fullScreen ? '1 1 100%' : '0 0 auto',
}}
>
<InputLabel>{t('Tracker')}</InputLabel>
<Select value={selectedTracker} onChange={e => setSelectedTracker(e.target.value)} label={t('Tracker')}>
<MenuItem value={-1}>{t('AllTrackers')}</MenuItem>
{enableRutor && <MenuItem value='rutor'>{t('Rutor')}</MenuItem>}
{trackers.map((tracker, index) => (
<MenuItem key={`${tracker.Host}-${tracker.Key}`} value={index}>
{tracker.Name || tracker.Host}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label={t('Torznab.SearchTorznab')}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
variant='outlined'
size='small'
fullWidth
placeholder={t('Torznab.SearchMoviesShows')}
autoFocus
/>
<Button
variant='contained'
color='primary'
onClick={handleSearch}
disabled={loading}
style={{
minWidth: fullScreen ? '80px' : '100px',
height: '40px',
}}
>
{loading ? <CircularProgress size={24} color='inherit' /> : t('Search')}
</Button>
</div>
{searched && results.length > 0 && (
<div
style={{
display: 'flex',
gap: isMobile ? '8px' : '4px',
marginBottom: '16px',
alignItems: 'center',
padding: isMobile ? '12px 8px' : '8px 12px',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
borderRadius: '4px',
border: '1px solid rgba(0, 0, 0, 0.08)',
flexWrap: isMobile ? 'wrap' : 'nowrap',
}}
>
<FormControl
variant='outlined'
size='small'
style={{
minWidth: isMobile ? '100%' : 140,
flexShrink: 0,
flex: isMobile ? '1 1 100%' : '0 0 auto',
}}
>
<InputLabel>{t('Torznab.SortBy')}</InputLabel>
<Select value={sortField} onChange={e => setSortField(e.target.value)} label={t('Torznab.SortBy')}>
<MenuItem value=''>{t('Torznab.SortByNone')}</MenuItem>
<MenuItem value='size'>{t('Torznab.SortBySize')}</MenuItem>
<MenuItem value='seeds'>{t('Torznab.SortBySeeds')}</MenuItem>
<MenuItem value='peers'>{t('Torznab.SortByPeers')}</MenuItem>
</Select>
</FormControl>
{sortField && (
<IconButton
size='small'
onClick={toggleSortDirection}
title={sortDirection === 'asc' ? t('Torznab.SortAscending') : t('Torznab.SortDescending')}
style={{
marginLeft: isMobile ? 'auto' : '4px',
padding: '8px',
}}
>
{sortDirection === 'asc' ? <ArrowUpward /> : <ArrowDownward />}
</IconButton>
)}
</div>
)}
<div style={{ overflowY: 'auto', maxHeight: 'calc(100vh - 200px)' }}>
{searched && results.length === 0 && !loading && (
<Typography align='center' variant='body1' color='textSecondary'>
{t('Torznab.NoResultsFound')}
</Typography>
)}
<List>
{sortedResults.map((item, index) => {
const sizeBytes = parseSizeToBytes(item.Size || '0')
const formattedSize = formatSizeToClassicUnits(sizeBytes)
return (
<div key={item.Hash || item.Link || index}>
<ListItem button onClick={() => handleAdd(item)}>
<ListItemText
primary={item.Title}
secondary={
<>
<Typography component='span' variant='body2' color='textPrimary'>
{formattedSize}
</Typography>
{` • S: ${item.Seed || 0} P: ${item.Peer || 0}`}
</>
}
primaryTypographyProps={{
style: {
whiteSpace: isMobile ? 'normal' : 'inherit',
fontSize: isMobile ? '0.9rem' : 'inherit',
},
}}
secondaryTypographyProps={{
style: {
fontSize: isMobile ? '0.75rem' : 'inherit',
},
}}
/>
<ListItemSecondaryAction>
<IconButton
edge='end'
aria-label='add'
onClick={() => handleAdd(item)}
disabled={adding}
size={isMobile ? 'small' : 'medium'}
>
<DownloadIcon color='secondary' />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
<Divider component='li' />
</div>
)
})}
</List>
</div>
</div>
</Content>
<Snackbar open={!!successMsg} autoHideDuration={1500} onClose={handleAlertClose} message={successMsg} />
<Snackbar open={!!errorMsg} autoHideDuration={1500} onClose={handleAlertClose} message={errorMsg} />
<div
style={{
padding: '16px',
display: 'flex',
justifyContent: 'flex-end',
borderTop: '1px solid rgba(0,0,0,0.12)',
}}
>
<Button onClick={handleClose} color='secondary' variant='outlined'>
{t('Close')}
</Button>
</div>
</StyledDialog>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import SearchIcon from '@material-ui/icons/Search'
import ListItemText from '@material-ui/core/ListItemText'
import ListItem from '@material-ui/core/ListItem'
import SearchDialog from './SearchDialog'
export default function SearchDialogButton({ isOffline, isLoading }) {
const { t } = useTranslation()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const handleClickOpen = () => setIsDialogOpen(true)
const handleClose = () => setIsDialogOpen(false)
return (
<>
<ListItem button onClick={handleClickOpen} disabled={isOffline || isLoading}>
<ListItemIcon>
<SearchIcon />
</ListItemIcon>
<ListItemText primary={t('Search')} />
</ListItem>
{isDialogOpen && <SearchDialog handleClose={handleClose} />}
</>
)
}
+21
View File
@@ -0,0 +1,21 @@
import styled, { css } from 'styled-components'
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;
`}
`}
`
@@ -0,0 +1,68 @@
import { FormControlLabel, FormGroup, FormHelperText, Switch, Link } from '@material-ui/core'
import { isMacOS, isAppleDevice, isDesktop } from 'utils/Utils'
import { useTranslation } from 'react-i18next'
import { SecondarySettingsContent, SettingSectionLabel } from './style'
export default function MobileAppSettings({
isVlcUsed,
setIsVlcUsed,
isInfuseUsed,
setIsInfuseUsed,
isIinaUsed,
setIsIinaUsed,
}) {
const { t } = useTranslation()
const isMac = isMacOS()
const isApple = isAppleDevice()
const isDesktopPlatform = isDesktop()
return (
<SecondarySettingsContent>
<SettingSectionLabel>{t('SettingsDialog.MobileAppSettings')}</SettingSectionLabel>
<FormGroup>
<FormControlLabel
control={<Switch checked={isVlcUsed} onChange={() => setIsVlcUsed(prev => !prev)} color='secondary' />}
label={t('SettingsDialog.UseVLC')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.UseVLCHint')}</FormHelperText>
{isDesktopPlatform && (
<FormHelperText margin='none'>
{t('SettingsDialog.UseVLCDesktopHintPrefix')}{' '}
<Link
href='https://github.com/northsea4/vlc-protocol'
target='_blank'
rel='noopener noreferrer'
color='secondary'
>
vlc-protocol-handler
</Link>
</FormHelperText>
)}
{isApple && (
<>
<FormControlLabel
control={
<Switch checked={isInfuseUsed} onChange={() => setIsInfuseUsed(prev => !prev)} color='secondary' />
}
label={t('SettingsDialog.UseInfuse')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.UseInfuseHint')}</FormHelperText>
</>
)}
{isMac && (
<>
<FormControlLabel
control={<Switch checked={isIinaUsed} onChange={() => setIsIinaUsed(prev => !prev)} color='secondary' />}
label={t('SettingsDialog.UseIINA')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.UseIINAHint')}</FormHelperText>
</>
)}
</FormGroup>
</SecondarySettingsContent>
)
}
@@ -0,0 +1,177 @@
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 {
CacheLegendGrid,
CacheLegendDot,
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} ${t('MB')}`}
preloadCachePercentage={preloadCachePercentage}
/>
<CacheLegendGrid>
<CacheLegendDot color={cacheBeforeReaderColor} aria-hidden />
<div className='cache-legend-value'>
{100 - cachePercentage}% ({Math.round((cacheSize / 100) * (100 - cachePercentage))} {t('MB')})
</div>
<div className='cache-legend-desc'>{t('SettingsDialog.CacheBeforeReaderDesc')}</div>
<CacheLegendDot color={cacheAfterReaderColor} aria-hidden />
<div className='cache-legend-value'>
{cachePercentage}% ({Math.round((cacheSize / 100) * cachePercentage)} {t('MB')})
</div>
<div className='cache-legend-desc'>{t('SettingsDialog.CacheAfterReaderDesc')}</div>
</CacheLegendGrid>
<br />
<SliderInput
isProMode={isProMode}
title={t('SettingsDialog.CacheSize')}
value={cacheSize}
setValue={setCacheSize}
sliderMin={32}
sliderMax={1024}
inputMin={32}
inputMax={999999}
step={4}
onBlurCallback={value => setCacheSize(Math.round(value / 4) * 4)}
/>
<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} ${t('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>
)
}
@@ -0,0 +1,498 @@
import { useTranslation } from 'react-i18next'
import TextField from '@material-ui/core/TextField'
import {
Box,
Button,
CircularProgress,
FormControlLabel,
FormGroup,
FormHelperText,
InputAdornment,
InputLabel,
MenuItem,
Select,
Switch,
} from '@material-ui/core'
import { styled } from '@material-ui/core/styles'
import { useEffect, useMemo, useState } from 'react'
import { SecondarySettingsContent, SettingSectionLabel } from './style'
// Create a styled status message component
const StatusMessage = styled('div')(({ theme, severity }) => ({
padding: theme.spacing(1.5, 2),
marginTop: theme.spacing(1),
borderRadius: theme.shape.borderRadius,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor:
severity === 'error' ? '#f44336' : severity === 'success' ? '#4caf50' : severity === 'info' ? '#2196f3' : '#ff9800',
color: 'white',
'& button': {
color: 'white',
minWidth: 'auto',
padding: '4px 8px',
marginLeft: theme.spacing(1),
},
}))
export default function SecondarySettingsComponent({ settings, inputForm }) {
const { t } = useTranslation()
const [storageSettings, setStorageSettings] = useState({
settings: 'json',
viewed: 'bbolt',
})
const [storageStatus, setStorageStatus] = useState({ message: '', type: '' })
const [loading, setLoading] = useState(false)
const {
RetrackersMode,
TorrentDisconnectTimeout,
EnableDebug,
EnableDLNA,
EnableIPv6,
FriendlyName,
ForceEncrypt,
DisableTCP,
DisableUTP,
DisableUPNP,
DisableDHT,
DisablePEX,
DisableUpload,
DownloadRateLimit,
UploadRateLimit,
ConnectionsLimit,
PeersListenPort,
ResponsiveMode,
SslPort,
SslCert,
SslKey,
ShowFSActiveTorr,
EnableProxy,
ProxyHosts,
} = settings || {}
// Local state for ProxyHosts text input
const [proxyHostsText, setProxyHostsText] = useState('')
// Sync proxyHostsText with ProxyHosts when settings change
useEffect(() => {
const textValue = Array.isArray(ProxyHosts) ? ProxyHosts.join(', ') : ProxyHosts || ''
setProxyHostsText(textValue)
}, [ProxyHosts])
// Use useMemo to compute basePath once
const basePath = useMemo(() => {
if (typeof window !== 'undefined') {
return window.location.pathname.split('/')[1] || ''
}
return ''
}, [])
// Helper function to build API URL
const getApiUrl = useMemo(
() => endpoint => {
const prefix = basePath ? `/${basePath}` : ''
return `${prefix}${endpoint}`
},
[basePath],
)
useEffect(() => {
const loadStorageSettings = async () => {
try {
const response = await fetch(getApiUrl('/storage/settings')) // /api/storage/settings
if (response.ok) {
const prefs = await response.json()
setStorageSettings(prefs)
}
} catch (error) {
// eslint-disable-line no-console
}
}
loadStorageSettings()
}, [getApiUrl])
// Handle storage settings change
const handleStorageChange = event => {
const { name, value } = event.target
setStorageSettings(prev => ({
...prev,
[name]: value,
}))
}
// Save storage settings - add better error handling
const saveStorageSettings = async () => {
setLoading(true)
setStorageStatus({ message: t('SettingsDialog.Saving'), type: 'info' })
try {
const response = await fetch(getApiUrl('/storage/settings'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(storageSettings),
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Failed to save settings')
}
if (result.status === 'ok') {
setStorageStatus({
message: t('SettingsDialog.StorageSettingsSaved'),
type: 'success',
})
} else {
setStorageStatus({
message: t('SettingsDialog.SaveError') + (result.error || 'Unknown error'),
type: 'error',
})
}
} catch (error) {
setStorageStatus({
message: t('SettingsDialog.SaveError') + error.message,
type: 'error',
})
} finally {
setLoading(false)
}
}
return (
<SecondarySettingsContent>
<SettingSectionLabel>{t('SettingsDialog.AdditionalSettings')}</SettingSectionLabel>
<FormGroup>
<FormControlLabel
control={<Switch checked={EnableIPv6} onChange={inputForm} id='EnableIPv6' color='secondary' />}
label='IPv6'
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.EnableIPv6Hint')}</FormHelperText>
</FormGroup>
<FormGroup>
<FormControlLabel
control={<Switch checked={!DisableTCP} onChange={inputForm} id='DisableTCP' color='secondary' />}
label='TCP (Transmission Control Protocol)'
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.DisableTCPHint')}</FormHelperText>
</FormGroup>
<FormGroup>
<FormControlLabel
control={<Switch checked={!DisableUTP} onChange={inputForm} id='DisableUTP' color='secondary' />}
label='μTP (Micro Transport Protocol)'
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.DisableUTPHint')}</FormHelperText>
</FormGroup>
<FormGroup>
<FormControlLabel
control={<Switch checked={!DisablePEX} onChange={inputForm} id='DisablePEX' color='secondary' />}
label='PEX (Peer Exchange)'
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.DisablePEXHint')}</FormHelperText>
</FormGroup>
<FormGroup>
<FormControlLabel
control={<Switch checked={ForceEncrypt} onChange={inputForm} id='ForceEncrypt' color='secondary' />}
label={t('SettingsDialog.ForceEncrypt')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.ForceEncryptHint')}</FormHelperText>
</FormGroup>
<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 />
<FormGroup>
<FormControlLabel
control={<Switch checked={!DisableDHT} onChange={inputForm} id='DisableDHT' color='secondary' />}
label={t('SettingsDialog.DHT')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.DisableDHTHint')}</FormHelperText>
</FormGroup>
<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 />
<FormGroup>
<FormControlLabel
control={<Switch checked={!DisableUpload} onChange={inputForm} id='DisableUpload' color='secondary' />}
label={t('SettingsDialog.Upload')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.UploadHint')}</FormHelperText>
</FormGroup>
<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')}
helperText={t('SettingsDialog.PeersListenPortHint')}
value={PeersListenPort}
type='number'
variant='outlined'
fullWidth
/>
<FormGroup>
<FormControlLabel
control={<Switch checked={!DisableUPNP} onChange={inputForm} id='DisableUPNP' color='secondary' />}
label='UPnP (Universal Plug and Play)'
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.DisableUPNPHint')}</FormHelperText>
</FormGroup>
<FormGroup>
<FormControlLabel
control={<Switch checked={EnableDebug} onChange={inputForm} id='EnableDebug' color='secondary' />}
label={t('SettingsDialog.EnableDebug')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.EnableDebugHint')}</FormHelperText>
</FormGroup>
<FormGroup>
<FormControlLabel
control={<Switch checked={ResponsiveMode} onChange={inputForm} id='ResponsiveMode' color='secondary' />}
label={t('SettingsDialog.ResponsiveMode')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.ResponsiveModeHint')}</FormHelperText>
</FormGroup>
<br />
<FormGroup style={{ marginBottom: '20px' }}>
<InputLabel htmlFor='RetrackersMode'>{t('SettingsDialog.RetrackersMode')}</InputLabel>
<Select
native
type='number'
id='RetrackersMode'
name='RetrackersMode'
value={RetrackersMode}
onChange={inputForm}
variant='outlined'
margin='dense'
>
<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>
<FormHelperText style={{ marginTop: '8px' }}>{t('SettingsDialog.RetrackersModeHint')}</FormHelperText>
</FormGroup>
{/* DLNA Section */}
<SettingSectionLabel style={{ marginTop: '20px' }}>{t('DLNA')}</SettingSectionLabel>
<FormControlLabel
control={<Switch checked={EnableDLNA} onChange={inputForm} id='EnableDLNA' color='secondary' />}
label={t('SettingsDialog.DLNA')}
labelPlacement='start'
/>
<TextField
onChange={inputForm}
margin='normal'
id='FriendlyName'
label={t('SettingsDialog.FriendlyName')}
helperText={t('SettingsDialog.FriendlyNameHint')}
value={FriendlyName}
type='text'
variant='outlined'
fullWidth
/>
{/* HTTPS Section */}
<SettingSectionLabel style={{ marginTop: '20px' }}>{t('HTTPS')}</SettingSectionLabel>
<TextField
onChange={inputForm}
margin='normal'
id='SslPort'
label={t('SettingsDialog.SslPort')}
helperText={t('SettingsDialog.SslPortHint')}
value={SslPort}
type='number'
variant='outlined'
fullWidth
/>
<br />
<TextField
onChange={inputForm}
margin='normal'
id='SslCert'
label={t('SettingsDialog.SslCert')}
helperText={t('SettingsDialog.SslCertHint')}
value={SslCert}
type='url'
variant='outlined'
fullWidth
/>
<br />
<TextField
onChange={inputForm}
margin='normal'
id='SslKey'
label={t('SettingsDialog.SslKey')}
helperText={t('SettingsDialog.SslKeyHint')}
value={SslKey}
type='url'
variant='outlined'
fullWidth
/>
<br />
{/* TorrFS */}
<SettingSectionLabel style={{ marginTop: '20px' }}>{t('TorrFS')}</SettingSectionLabel>
<FormGroup>
<FormControlLabel
control={<Switch checked={ShowFSActiveTorr} onChange={inputForm} id='ShowFSActiveTorr' color='secondary' />}
label={t('SettingsDialog.ShowFSActiveTorr')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.ShowFSActiveTorrHint')}</FormHelperText>
</FormGroup>
{/* Storage Settings Section */}
<Box mt={4} mb={2}>
<SettingSectionLabel>{t('SettingsDialog.StorageConfiguration')}</SettingSectionLabel>
<FormGroup>
<InputLabel htmlFor='settings'>{t('SettingsDialog.SettingsStorage')}</InputLabel>
<Select
id='settings'
name='settings'
value={storageSettings.settings || 'json'}
onChange={handleStorageChange}
variant='outlined'
margin='dense'
>
<MenuItem value='json'>{t('SettingsDialog.JsonFile')} (settings.json)</MenuItem>
<MenuItem value='bbolt'>{t('SettingsDialog.BBoltDatabase')} (config.db)</MenuItem>
</Select>
<FormHelperText style={{ marginTop: '8px' }}>{t('SettingsDialog.SettingsStorageHint')}</FormHelperText>
</FormGroup>
<FormGroup style={{ marginTop: '16px' }}>
<InputLabel htmlFor='viewed'>{t('SettingsDialog.ViewedHistoryStorage')}</InputLabel>
<Select
id='viewed'
name='viewed'
value={storageSettings.viewed || 'bbolt'}
onChange={handleStorageChange}
variant='outlined'
margin='dense'
>
<MenuItem value='bbolt'>{t('SettingsDialog.BBoltDatabase')} (config.db)</MenuItem>
<MenuItem value='json'>{t('SettingsDialog.JsonFile')} (viewed.json)</MenuItem>
</Select>
<FormHelperText style={{ marginTop: '8px' }}>{t('SettingsDialog.ViewedStorageHint')}</FormHelperText>
</FormGroup>
<Box mt={2} mb={2}>
<Button
variant='contained'
color='primary'
onClick={saveStorageSettings}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{t('SettingsDialog.SaveStorageSettings')}
</Button>
</Box>
{storageStatus.message && (
<StatusMessage severity={storageStatus.type}>
<span>{storageStatus.message}</span>
<Button onClick={() => setStorageStatus({ message: '', type: '' })} size='small'>
×
</Button>
</StatusMessage>
)}
</Box>
{/* ProxyP2P */}
<SettingSectionLabel style={{ marginTop: '20px' }}>{t('Proxy')}</SettingSectionLabel>
<FormGroup>
<FormControlLabel
control={<Switch checked={EnableProxy} onChange={inputForm} id='EnableProxy' color='secondary' />}
label={t('SettingsDialog.EnableProxy')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.EnableProxyHint')}</FormHelperText>
</FormGroup>
{/* Proxy hosts */}
<TextField
onChange={e => {
setProxyHostsText(e.target.value)
}}
onBlur={e => {
const inputValue = e.target.value.trim()
const hostsArray =
inputValue === ''
? []
: inputValue
.split(',')
.map(s => s.trim())
.filter(s => s !== '')
inputForm({
target: {
id: 'ProxyHosts',
value: hostsArray,
},
})
}}
margin='normal'
id='ProxyHosts'
label={t('SettingsDialog.ProxyHosts')}
helperText={t('SettingsDialog.ProxyHostsHint')}
value={proxyHostsText}
type='text'
variant='outlined'
fullWidth
/>
</SecondarySettingsContent>
)
}
@@ -0,0 +1,224 @@
import axios from 'axios'
import Button from '@material-ui/core/Button'
import Switch from '@material-ui/core/Switch'
import { FormControlLabel, useMediaQuery, useTheme } from '@material-ui/core'
import { settingsHost } from 'utils/Hosts'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { clearTMDBCache } from 'components/Add/helpers'
import AppBar from '@material-ui/core/AppBar'
import SwipeableViews from 'react-swipeable-views'
import CircularProgress from '@material-ui/core/CircularProgress'
import { StyledDialog } from 'style/CustomMaterialUiStyles'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
import { SettingsHeader, FooterSection, Content, StyledTabs, StyledTab } from './style'
import defaultSettings from './defaultSettings'
import { a11yProps, TabPanel } from './tabComponents'
import PrimarySettingsComponent from './PrimarySettingsComponent'
import SecondarySettingsComponent from './SecondarySettingsComponent'
import MobileAppSettings from './MobileAppSettings'
import TorznabSettings from './TorznabSettings'
import TMDBSettings from './TMDBSettings'
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)
const [isVlcUsed, setIsVlcUsed] = useState(JSON.parse(localStorage.getItem('isVlcUsed')) ?? false)
const [isInfuseUsed, setIsInfuseUsed] = useState(JSON.parse(localStorage.getItem('isInfuseUsed')) ?? false)
const [isIinaUsed, setIsIinaUsed] = useState(JSON.parse(localStorage.getItem('isIinaUsed')) ?? false)
useEffect(() => {
axios.post(settingsHost(), { action: 'get' }).then(({ data }) => {
setSettings({ ...data, CacheSize: data.CacheSize / (1024 * 1024) })
})
}, [])
const ref = useOnStandaloneAppOutsideClick(handleClose)
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 })
// Clear TMDB cache so fresh settings are fetched on next poster search
clearTMDBCache()
localStorage.setItem('isVlcUsed', isVlcUsed)
localStorage.setItem('isInfuseUsed', isInfuseUsed)
localStorage.setItem('isIinaUsed', isIinaUsed)
}
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' || type === 'text') {
sets[id] = value
} else if (!type && value !== undefined) {
// Fallback for custom handlers that don't provide type (e.g., ProxyHosts array)
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 (
<StyledDialog open onClose={handleClose} fullScreen={fullScreen} fullWidth maxWidth='md' ref={ref}>
<SettingsHeader>
<div>{t('SettingsDialog.Settings')}</div>
<FormControlLabel
control={
<Switch
checked={isProMode}
onChange={({ target: { checked } }) => {
setIsProMode(checked)
localStorage.setItem('isProMode', checked)
if (!checked) setSelectedTab(0)
}}
style={{ color: 'white' }}
/>
}
label={t('SettingsDialog.ProMode')}
labelPlacement='start'
/>
</SettingsHeader>
<AppBar position='static' color='default'>
<StyledTabs
value={selectedTab}
onChange={handleChange}
indicatorColor='secondary'
textColor='secondary'
variant='scrollable'
scrollButtons='auto'
>
<StyledTab label={t('SettingsDialog.Tabs.Main')} {...a11yProps(0)} />
<StyledTab
disabled={!isProMode}
label={
<>
<span>{t('SettingsDialog.Tabs.Additional')}</span>
{!isProMode && <span className='disabled-hint'>{t('SettingsDialog.Tabs.AdditionalDisabled')}</span>}
</>
}
{...a11yProps(1)}
/>
<StyledTab label={t('Search')} {...a11yProps(2)} />
<StyledTab label={t('SettingsDialog.Tabs.App')} {...a11yProps(3)} />
</StyledTabs>
</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} updateSettings={updateSettings} />
</TabPanel>
<TabPanel value={selectedTab} index={2} dir={direction}>
<TorznabSettings settings={settings} inputForm={inputForm} updateSettings={updateSettings} />
</TabPanel>
<TabPanel value={selectedTab} index={3} dir={direction}>
<TMDBSettings settings={settings} updateSettings={updateSettings} />
<MobileAppSettings
isVlcUsed={isVlcUsed}
setIsVlcUsed={setIsVlcUsed}
isInfuseUsed={isInfuseUsed}
setIsInfuseUsed={setIsInfuseUsed}
isIinaUsed={isIinaUsed}
setIsIinaUsed={setIsIinaUsed}
/>
</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)
// Clear TMDB cache when resetting to defaults
clearTMDBCache()
}}
color='secondary'
variant='outlined'
>
{t('SettingsDialog.ResetToDefault')}
</Button>
<Button variant='contained' onClick={handleSave} color='secondary'>
{t('Save')}
</Button>
</FooterSection>
</StyledDialog>
)
}
@@ -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>
</>
)
}
@@ -0,0 +1,86 @@
import { useTranslation } from 'react-i18next'
import { FormGroup, FormHelperText, TextField } from '@material-ui/core'
import { SecondarySettingsContent, SettingSectionLabel } from './style'
export default function TMDBSettings({ settings, updateSettings }) {
const { t } = useTranslation()
const { TMDBSettings } = settings || {}
const {
APIKey = '',
APIURL = 'https://api.themoviedb.org/3',
ImageURL = 'https://image.tmdb.org',
ImageURLRu = 'https://imagetmdb.com',
} = TMDBSettings || {}
const handleChange = (field, value) => {
updateSettings({
TMDBSettings: {
...TMDBSettings,
[field]: value,
},
})
}
return (
<SecondarySettingsContent>
<SettingSectionLabel>{t('TMDB.Settings')}</SettingSectionLabel>
<FormGroup>
<TextField
label={t('TMDB.APIKey')}
value={APIKey}
onChange={e => handleChange('APIKey', e.target.value)}
placeholder='Enter your TMDB API key'
variant='outlined'
size='small'
fullWidth
style={{ marginBottom: 15 }}
/>
<FormHelperText margin='none'>{t('TMDB.APIKeyHint')}</FormHelperText>
</FormGroup>
<FormGroup style={{ marginTop: 20 }}>
<TextField
label={t('TMDB.APIURL')}
value={APIURL}
onChange={e => handleChange('APIURL', e.target.value)}
placeholder='https://api.themoviedb.org/3'
variant='outlined'
size='small'
fullWidth
style={{ marginBottom: 10 }}
/>
<FormHelperText margin='none'>{t('TMDB.APIURLHint')}</FormHelperText>
</FormGroup>
<FormGroup style={{ marginTop: 20 }}>
<TextField
label={t('TMDB.ImageURL')}
value={ImageURL}
onChange={e => handleChange('ImageURL', e.target.value)}
placeholder='https://image.tmdb.org'
variant='outlined'
size='small'
fullWidth
style={{ marginBottom: 10 }}
/>
<FormHelperText margin='none'>{t('TMDB.ImageURLHint')}</FormHelperText>
</FormGroup>
<FormGroup style={{ marginTop: 20 }}>
<TextField
label={t('TMDB.ImageURLRu')}
value={ImageURLRu}
onChange={e => handleChange('ImageURLRu', e.target.value)}
placeholder='https://imagetmdb.com'
variant='outlined'
size='small'
fullWidth
style={{ marginBottom: 10 }}
/>
<FormHelperText margin='none'>{t('TMDB.ImageURLRuHint')}</FormHelperText>
</FormGroup>
<br />
</SecondarySettingsContent>
)
}
@@ -0,0 +1,194 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
FormControlLabel,
FormGroup,
FormHelperText,
Switch,
TextField,
Button,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Typography,
} from '@material-ui/core'
import CircularProgress from '@material-ui/core/CircularProgress'
import DeleteIcon from '@material-ui/icons/Delete'
import axios from 'axios'
import { torznabTestHost } from 'utils/Hosts'
import { SecondarySettingsContent, SettingSectionLabel } from './style'
export default function TorznabSettings({ settings, inputForm, updateSettings }) {
const { t } = useTranslation()
const { EnableRutorSearch, EnableTorznabSearch, TorznabUrls } = settings || {}
const [newHost, setNewHost] = useState('')
const [newKey, setNewKey] = useState('')
const [newName, setNewName] = useState('')
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState(null)
const handleAdd = () => {
if (newHost && newKey) {
const currentUrls = TorznabUrls || []
updateSettings({ TorznabUrls: [...currentUrls, { Host: newHost, Key: newKey, Name: newName }] })
setNewHost('')
setNewKey('')
setNewName('')
}
}
const handleDelete = index => {
const currentUrls = TorznabUrls || []
const newUrls = [...currentUrls]
newUrls.splice(index, 1)
updateSettings({ TorznabUrls: newUrls })
}
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
const { data } = await axios.post(torznabTestHost(), {
host: newHost,
key: newKey,
})
if (data.success) {
setTestResult({ success: true, msg: t('Torznab.ConnectionSuccessful') })
} else {
setTestResult({ success: false, msg: data.error })
}
} catch (e) {
setTestResult({ success: false, msg: e.message })
}
setTesting(false)
}
return (
<SecondarySettingsContent>
<SettingSectionLabel>{t('Search')}</SettingSectionLabel>
<FormGroup>
<FormControlLabel
control={<Switch checked={EnableRutorSearch} onChange={inputForm} id='EnableRutorSearch' color='secondary' />}
label={t('SettingsDialog.EnableRutorSearch')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('SettingsDialog.EnableRutorSearchHint')}</FormHelperText>
</FormGroup>
<FormGroup>
<FormControlLabel
control={
<Switch
checked={EnableTorznabSearch || false}
onChange={inputForm}
id='EnableTorznabSearch'
color='secondary'
/>
}
label={t('Torznab.EnableTorznabSearch')}
labelPlacement='start'
/>
<FormHelperText margin='none'>{t('Torznab.EnableSearchViaTorznab')}</FormHelperText>
</FormGroup>
<div
style={{
padding: '20px 0',
opacity: EnableTorznabSearch ? 1 : 0.5,
pointerEvents: EnableTorznabSearch ? 'auto' : 'none',
}}
>
<List dense>
{(TorznabUrls || []).map((url, index) => (
<ListItem key={`${url.Host}-${url.Key}`} style={{ paddingLeft: 0, paddingRight: 48 }}>
<ListItemText
primary={url.Name || url.Host}
secondary={
<>
{url.Name && (
<Typography component='span' variant='body2' display='block' color='textSecondary'>
{url.Host}
</Typography>
)}
{`Key: ${url.Key.substring(0, 5)}...`}
</>
}
primaryTypographyProps={{ style: { wordBreak: 'break-all' } }}
secondaryTypographyProps={{ style: { wordBreak: 'break-all' } }}
/>
<ListItemSecondaryAction>
<IconButton edge='end' aria-label='delete' onClick={() => handleDelete(index)}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
marginTop: 10,
}}
>
<TextField
label={t('Torznab.NameOptional')}
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder='My Tracker'
variant='outlined'
size='small'
fullWidth
/>
<TextField
label={t('Torznab.TorznabHostURL')}
value={newHost}
onChange={e => setNewHost(e.target.value)}
placeholder='http://localhost:9117'
variant='outlined'
size='small'
fullWidth
/>
<TextField
label={t('Torznab.APIKey')}
value={newKey}
onChange={e => setNewKey(e.target.value)}
variant='outlined'
size='small'
fullWidth
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
<Button
variant='outlined'
color='secondary'
onClick={handleTest}
disabled={!newHost || !newKey || testing}
style={{ flex: '1 1 auto', minWidth: 100 }}
>
{testing ? <CircularProgress size={24} color='inherit' /> : t('Torznab.Test')}
</Button>
<Button
variant='contained'
color='secondary'
onClick={handleAdd}
disabled={!newHost || !newKey}
style={{ flex: '1 1 auto', minWidth: 100 }}
>
{t('Torznab.AddServer')}
</Button>
</div>
{testResult && (
<Typography variant='caption' style={{ color: testResult.success ? 'green' : 'red' }}>
{testResult.msg}
</Typography>
)}
</div>
</div>
<br />
</SecondarySettingsContent>
)
}
@@ -0,0 +1,34 @@
export default {
CacheSize: 64,
ReaderReadAHead: 95,
PreloadCache: 50,
UseDisk: false,
TorrentsSavePath: '',
RemoveCacheOnDrop: false,
ForceEncrypt: false,
RetrackersMode: 1,
TorrentDisconnectTimeout: 30,
EnableDebug: false,
EnableDLNA: false,
FriendlyName: '',
EnableRutorSearch: false,
EnableIPv6: false,
DisableTCP: false,
DisableUTP: false,
DisableUPNP: false,
DisableDHT: false,
DisablePEX: false,
DisableUpload: false,
DownloadRateLimit: 0,
UploadRateLimit: 0,
ConnectionsLimit: 25,
PeersListenPort: 0,
ResponsiveMode: true,
SslPort: 0,
SslCert: '',
SslKey: '',
ShowFSActiveTorr: true,
StoreSettingsInJson: true,
EnableProxy: false,
ProxyHosts: ['*themoviedb.org', '*tmdb.org', 'rutor.info'],
}

Some files were not shown because too many files have changed in this diff Show More