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
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"plugins": ["@babel/plugin-transform-react-jsx", "@babel/plugin-proposal-class-properties"],
|
||||
"env": {
|
||||
"production": {
|
||||
"presets": ["minify"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
REACT_APP_SERVER_HOST=
|
||||
REACT_APP_TMDB_API_KEY=
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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": "./"
|
||||
}
|
||||
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 411 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 734 KiB |
|
After Width: | Height: | Size: 595 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 624 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 596 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 691 KiB |
|
After Width: | Height: | Size: 848 KiB |
|
After Width: | Height: | Size: 730 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 236 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 824 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -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&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>
|
||||
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
`)}
|
||||
`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||