update readme

This commit is contained in:
Daan 2020-07-08 08:34:14 +02:00
commit 280de2e651
42 changed files with 15477 additions and 12865 deletions

25
.eslintrc.js Normal file
View file

@ -0,0 +1,25 @@
module.exports = {
root: true,
env: {
node: true
},
extends: ['plugin:vue/essential', 'eslint:recommended', '@vue/prettier'],
parserOptions: {
parser: 'babel-eslint'
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
},
overrides: [
{
files: [
'**/__tests__/*.{j,t}s?(x)',
'**/tests/unit/**/*.spec.{j,t}s?(x)'
],
env: {
jest: true
}
}
]
}

View file

@ -1,23 +1,28 @@
name: Build & Release
on:
push:
branches:
- development
jobs:
github-release-development:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
- run: npm install
- run: npm run build
- uses: montudor/action-zip@v0.1.0
with:
args: zip -qq -r ./release.zip ./vuetorrent
- uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "dev-latest"
prerelease: true
title: "Development Build"
files: release.zip
name: Build & Release
on:
push:
branches:
- development
jobs:
github-release-development:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Install & build
uses: actions/setup-node@master
- run: npm install
- run: npm run build
- name: Zip it
uses: montudor/action-zip@v0.1.0
with:
args: zip -qq -r ./release.zip ./vuetorrent
- name: Get Version
run: echo ::set-env name=VERSION::$(jq -r .version package.json)
- name: Push release
uses: 'marvinpinto/action-automatic-releases@latest'
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
automatic_release_tag: 'dev-v${{ env.VERSION }}'
prerelease: true
title: 'Development Build v${{ env.VERSION }}'
files: release.zip

View file

@ -1,23 +1,28 @@
name: Build & Release
on:
push:
branches:
- master
jobs:
github-release-production:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
- run: npm install
- run: npm run build
- uses: montudor/action-zip@v0.1.0
with:
args: zip -qq -r ./release.zip ./vuetorrent
- uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "prod-latest"
prerelease: true
title: "Stable Build"
files: release.zip
name: Build & Release
on:
push:
branches:
- master
jobs:
github-release-production:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Install & build
uses: actions/setup-node@master
- run: npm install
- run: npm run build
- name: Zip it
uses: montudor/action-zip@v0.1.0
with:
args: zip -qq -r ./release.zip ./vuetorrent
- name: Get Version
run: echo ::set-env name=VERSION::$(jq -r .version package.json)
- name: Push release
uses: 'marvinpinto/action-automatic-releases@latest'
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
automatic_release_tag: 'prod-v${{ env.VERSION }}'
prerelease: true
title: 'Stable Build v${{ env.VERSION }}'
files: release.zip

2
.gitignore vendored
View file

@ -19,3 +19,5 @@ yarn-error.log*
*.njsproj
*.sln
*.sw?
vuetorrent

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"tabWidth": 4,
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid"
}

View file

@ -7,15 +7,11 @@ The sleekest looking WEBUI for qBittorrent made with Vuejs!
## Screenshots
<p align="center">
<a href="https://i.imgur.com/vPBcrK4.png"><img src="https://i.imgur.com/vPBcrK4.png" title="Desktop" alt="Desktop Screenshot" ></a>
<a href="https://imgur.com/xgwECT2.png"><img src="https://imgur.com/xgwECT2.png" title="Desktop" alt="Desktop Screenshot" ></a>
</p>
<p align="center">
<a href="https://i.imgur.com/SUOEyy9.png"><img src="https://i.imgur.com/SUOEyy9.png" title="Mobile" alt="Mobile Screenshot" width="320" height="540"></a>
</p>
## Installation
@ -32,20 +28,33 @@ The sleekest looking WEBUI for qBittorrent made with Vuejs!
- clone the repo
- npm install
* npm install
- npm run serve
## Features
- viewing sessions stats ( down / upload speed, session uploaded / downloaded )
- viewing sessions status ( down / upload speed, session uploaded / downloaded )
- adding / removing / pausing / resuming torrents
- sorting by every property shown!
* sorting by every property shown!
* mobile friendly! (maybe not for thousands of torrents...)
- mobile friendly! (maybe not for thousands of torrents...)
- works on QBittorrent V4.2 and later
- torrent info / trackers / peers / content
* works on QBittorrent V4.2 and later
### Sorting/Filtring
example queries:
- s name asc => sort by name ascending
- sort size desc => sort by size descending
- f ubuntu => filter by torrent-name that contains 'ubuntu'
- filter done => filter all completed torrents
- filt busy => filter all downloading torrents
## Contributing
@ -70,6 +79,5 @@ Reach out to me at one of the following places!
## Credits
- Dashboard design heavily inspired by: '[Net Ninja - Vuetify](https://github.com/iamshaunjp/vuetify-playlist)'.
Also check out The Net Ninja's Youtube Channel.
* This repo '[CzBiX qb-web ](https://github.com/CzBiX/qb-web)'

24500
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,55 +1,54 @@
{
"name": "vuetorrent",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@babel/polyfill": "^7.4.4",
"apexcharts": "^3.19.2",
"axios": "^0.19.2",
"core-js": "^3.6.4",
"register-service-worker": "^1.7.1",
"vue": "^2.6.11",
"vue-apexcharts": "^1.5.3",
"vue-observe-visibility": "^0.4.6",
"vue-router": "^3.2.0",
"vue-toastification": "^1.7.1",
"vuetify": "^2.2.11",
"vuex": "^3.4.0",
"vuex-persist": "^2.2.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.3.0",
"@vue/cli-plugin-eslint": "~4.3.0",
"@vue/cli-service": "~4.3.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-cli-plugin-vuetify": "~2.0.5",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
"name": "vuetorrent",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"format": "pretty-quick"
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
"dependencies": {
"@babel/polyfill": "^7.4.4",
"apexcharts": "^3.19.2",
"axios": "^0.19.2",
"core-js": "^3.6.4",
"dayjs": "^1.8.27",
"register-service-worker": "^1.7.1",
"vue": "^2.6.11",
"vue-apexcharts": "^1.5.3",
"vue-async-computed": "^3.8.2",
"vue-context": "^5.1.0",
"vue-observe-visibility": "^0.4.6",
"vue-router": "^3.2.0",
"vue-toastification": "^1.7.1",
"vue2-perfect-scrollbar": "^1.5.0",
"vuetify": "^2.2.29",
"vuex": "^3.4.0",
"vuex-persist": "^2.2.0"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie <= 10"
]
"devDependencies": {
"@vue/cli-plugin-babel": "~4.3.0",
"@vue/cli-plugin-eslint": "~4.3.0",
"@vue/cli-service": "~4.3.0",
"@vue/eslint-config-prettier": "^6.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-vue": "^6.2.2",
"fibers": "^5.0.0",
"node-sass": "^4.14.1",
"prettier": "^2.0.5",
"pretty-quick": "^2.0.1",
"sass": "^1.26.5",
"sass-loader": "^8.0.2",
"vue-cli-plugin-vuetify": "~2.0.5",
"vue-template-compiler": "^2.6.11"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie <= 10"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -1,49 +1,41 @@
<template>
<v-app class="background">
<v-app :style="{ backgroundColor: background }">
<AddModal />
<div v-if="authenticated" class="background">
<keep-alive><Navbar /></keep-alive>
<v-content class="mx-4 mb-4">
<router-view></router-view>
</v-content>
</div>
<v-container v-else fill-height>
<v-layout
row
wrap
align-center
class="justify-center"
justify-center
>
<div style="margin: 0 auto;">
<Login />
</div>
</v-layout>
</v-container>
<div class="background">
<p class="grey--text caption text-center">
Made by Daan Wijns
</p>
</div>
<SettingsModal />
<Navbar v-if="isAuthenticated" />
<v-content fill-height fill-width>
<router-view></router-view>
</v-content>
</v-app>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import Navbar from './components/Navbar.vue'
import Login from './components/Login.vue'
import Navbar from '@/components/Navbar.vue'
import { isAuthenticated } from '@/services/auth.js'
export default {
components: { Navbar, Login },
components: { Navbar },
name: 'App',
data() {
return {}
},
methods: {
async getAuth() {
return await isAuthenticated()
}
},
computed: {
...mapState(['authenticated', 'rid', 'mainData', 'preferences']),
...mapState(['rid', 'mainData', 'preferences']),
...mapGetters(['getTheme']),
theme() {
return this.getTheme() ? 'dark' : 'light'
},
background() {
return this.$vuetify.theme.themes[this.theme].background
},
isAuthenticated() {
return this.getAuth()
}
}
}

18
src/assets/styles.scss Normal file
View file

@ -0,0 +1,18 @@
@import '~vuetify/src/styles/styles.sass';
@mixin dialog-title {
@include theme(v-card) using($material) {
.v-card__title {
background-color: map-get($material, 'app-bar');
}
}
}
@mixin dark-mode-value($light, $dark) {
&.theme--light {
@content ($light);
}
&.theme--dark {
@content ($dark);
}
}

View file

@ -1,75 +0,0 @@
<template>
<v-container class="grey lighten-4">
<v-card max-width="400" flat>
<v-container :class="`pa-3 project done`">
<v-card-title class="justify-center">
<h2>Login</h2>
</v-card-title>
<div class="mr-5 ml-5"></div>
<v-card-text>
<v-form class="px-3" ref="form">
<v-text-field
flat
solo
background-color="grey lighten-4"
label="username"
prepend-icon="person"
v-model="username"
:rules="inputRules"
@keyup.enter.native="Login"
autocomplete="current email"
></v-text-field>
<v-text-field
flat
solo
background-color="grey lighten-4"
type="password"
label="password"
prepend-icon="lock"
v-model="password"
:rules="inputRules"
@keyup.enter.native="Login"
autocomplete="current password"
></v-text-field>
<v-spacer></v-spacer>
<v-card-actions class="justify-center">
<v-btn
:loading="loading"
text
@click="Login"
class="blue_accent white--text mx-0 mt-3"
>Login</v-btn
>
</v-card-actions>
</v-form>
</v-card-text>
</v-container>
</v-card>
</v-container>
</template>
<script>
import { mapState } from 'vuex'
export default {
data() {
return {
username: '',
password: '',
inputRules: [v => v.length >= 1 || 'At least 1 character']
}
},
methods: {
Login() {
this.$store.state.loading = true
this.$store.dispatch('LOGIN', {
username: this.username,
password: this.password
})
}
},
computed: {
...mapState(['loading'])
}
}
</script>

View file

@ -1,132 +1,142 @@
<template>
<v-dialog
max-width="500px"
v-model="dialog"
>
<v-card>
<v-container :class="`pa-0 project done`">
<v-card-title class="justify-center">
<h2>Add a new Torrent</h2>
</v-card-title>
<v-card-text>
<v-form v-model="valid" ref="form">
<v-container>
<v-row no-gutters>
<v-col ref="fileZone">
<v-file-input
v-model="files"
color="deep-purple accent-4"
counter
label="File input"
multiple
placeholder="Select your files"
prepend-icon="mdi-paperclip"
outlined
:show-size="1000"
>
<template v-slot:selection="{ index, text }">
<v-chip
v-if="index < 2"
color="deep-purple accent-4"
dark
label
small
>
{{ text }}
</v-chip>
<v-dialog max-width="500px" v-model="dialog">
<v-card>
<v-container :class="`pa-0 project done`">
<v-card-title class="justify-center">
<h2>Add a new Torrent</h2>
</v-card-title>
<v-card-text>
<v-form v-model="valid" ref="form">
<v-container>
<v-row no-gutters>
<v-col ref="fileZone">
<v-file-input
v-model="files"
color="deep-purple accent-4"
counter
label="File input"
multiple
placeholder="Select your files"
prepend-icon="mdi-paperclip"
outlined
:show-size="1000"
>
<template
v-slot:selection="{ index, text }"
>
<v-chip
v-if="index < 2"
color="deep-purple accent-4"
dark
label
small
>
{{ text }}
</v-chip>
<span
v-else-if="index === 2"
class="overline grey--text text--darken-3 mx-2"
>
+{{ files.length - 2 }} File(s)
</span>
</template>
</v-file-input>
<v-text-field
label="URL"
prepend-icon="mdi-link"
:rows="$vuetify.breakpoint.xsOnly ? 1 : 3"
required
:autofocus="!phoneLayout"
v-model="url"
/>
</v-col>
</v-row>
<span
v-else-if="index === 2"
class="overline grey--text text--darken-3 mx-2"
>
+{{ files.length - 2 }} File(s)
</span>
</template>
</v-file-input>
<v-text-field
label="URL"
prepend-icon="mdi-link"
:rows="
$vuetify.breakpoint.xsOnly ? 1 : 3
"
required
:autofocus="!phoneLayout"
v-model="url"
/>
</v-col>
</v-row>
<v-text-field
v-model="directory"
label="Download Directory"
prepend-icon="folder"
></v-text-field>
<v-text-field
v-model="directory"
:placeholder="savepath"
label="Download Directory"
prepend-icon="folder"
></v-text-field>
</v-container>
</v-form>
</v-card-text>
<v-spacer></v-spacer>
<v-form>
<v-card-actions class="justify-center">
<v-btn
text
@click="submit"
:disabled="!valid"
class="blue_accent white--text mx-0 mt-3"
>Add Torrent</v-btn
>
</v-card-actions>
</v-form>
</v-container>
</v-form>
</v-card-text>
<v-spacer></v-spacer>
<v-form>
<v-card-actions class="justify-center">
<v-btn
:loading="loading"
text
@click="submit"
:disabled="!valid"
class="blue_accent white--text mx-0 mt-3"
>Add Torrent</v-btn
>
</v-card-actions>
</v-form>
</v-container>
</v-card>
</v-dialog>
</v-card>
</v-dialog>
</template>
<script>
import Modal from "@/mixins/Modal";
import { mapGetters } from 'vuex'
import Modal from '@/mixins/Modal'
import qbit from '@/services/qbit'
export default {
name: "AddModal",
mixins: [Modal],
data() {
return {
files: [],
directory: "",
inputRules: [
(v) =>
v.indexOf("magnet") > -1 ||
v.indexOf("http") > -1 ||
this.validFile ||
"Not a valid magnet link",
],
loading: false,
url: null,
valid: false
};
},
methods: {
submit() {
if(this.files.length || this.url){
let torrents = []
let params= { urls: null};
if(this.files.length) torrents.push(...this.files)
if(this.url) params.urls = this.url
qbit.addTorrents(params, torrents)
name: 'AddModal',
mixins: [Modal],
data() {
return {
files: [],
directory: '',
inputRules: [
v =>
v.indexOf('magnet') > -1 ||
v.indexOf('http') > -1 ||
this.validFile ||
'Not a valid magnet link'
],
loading: false,
url: null,
valid: false
}
},
methods: {
submit() {
if (this.files.length || this.url) {
let torrents = []
let params = { urls: null }
if (this.files.length) torrents.push(...this.files)
if (this.url) params.urls = this.url
if (this.directory) params.savepath = this.directory
this.url = null
qbit.addTorrents(params, torrents)
this.$store.commit('TOGGLE_MODAL', 'addmodal')
}
this.resetForm()
this.$store.commit('TOGGLE_MODAL', 'addmodal')
}
},
resetForm() {
this.url = null
;(this.files = []), (this.directory = null)
}
},
},
computed: {
validFile() {
return this.Files.length > 0;
},
phoneLayout() {
return this.$vuetify.breakpoint.xsOnly;
},
},
};
computed: {
...mapGetters(['getSettings']),
validFile() {
return this.Files.length > 0
},
phoneLayout() {
return this.$vuetify.breakpoint.xsOnly
},
savepath() {
return this.getSettings().savePath
}
}
}
</script>

View file

@ -0,0 +1,99 @@
<template>
<v-dialog max-width="500px" v-model="dialog">
<v-card>
<v-container
style="min-height: 300px;"
:class="`pa-0 project done`"
>
<v-card-title class="justify-center">
<h2>Settings</h2>
</v-card-title>
<v-form>
<v-card-actions class="justify-center">
<v-btn
text
@click="switchOldUI"
class="green_accent white--text mx-0 mt-3"
>switch to old ui</v-btn
>
</v-card-actions>
</v-form>
<v-form class="px-6 mt-3">
<v-container>
<v-switch
class="v-input--reverse v-input--expand"
inset
v-model="freeSpace"
color="green_accent"
>
<template #label>
<span class="grey--text">
Show Free Space
</span>
</template>
</v-switch>
</v-container>
</v-form>
</v-container>
<v-card-actions class="justify-center pb-5 project done">
<v-btn
text
@click="save"
class="green_accent white--text mx-0 mt-3"
>Save</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { mapState } from 'vuex'
import Modal from '@/mixins/Modal'
import qbit from '@/services/qbit'
export default {
name: 'SettingsModal',
mixins: [Modal],
methods: {
async switchOldUI() {
await qbit.switchToOldUi()
window.location.reload(true)
},
save() {
this.$store.commit('TOGGLE_MODAL', 'settingsmodal')
}
},
computed: {
...mapState(['webuiSettings']),
freeSpace: {
get() {
return this.webuiSettings.showFreeSpace
},
set(val) {
this.webuiSettings.showFreeSpace = val
}
}
}
}
</script>
<style lang="scss" scoped>
// Reversed input variant
::v-deep .v-input--reverse .v-input__slot {
flex-direction: row-reverse;
justify-content: flex-end;
.v-application--is-ltr & {
.v-input--selection-controls__input {
margin-right: 0;
margin-left: 8px;
}
}
.v-application--is-rtl & {
.v-input--selection-controls__input {
margin-left: 0;
margin-right: 8px;
}
}
}
</style>

View file

@ -1,274 +1,413 @@
<template>
<nav>
<!--title-->
<v-app-bar flat color="background">
<v-btn
@click="drawer = !drawer"
text
fab
class="grey--text text--lighten-1"
>
<v-icon>menu</v-icon>
</v-btn>
<v-toolbar-title
:class="[
'grey--text',
{ 'subheading ml-0': $vuetify.breakpoint.smAndDown }
]"
>
<span class="font-weight-light">Vue</span>
<span>Torrent</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<!--right corner functions-->
<v-btn
text
small
fab
color="grey"
class="mr-0 ml-0"
@click="toggleModal('addmodal')"
>
<v-icon color="grey">add</v-icon>
</v-btn>
<v-btn small fab text class="mr-0 ml-0" @click="removeTorrents">
<v-icon color="grey">remove</v-icon>
</v-btn>
<v-btn small fab text class="mr-0 ml-0" @click="resumeTorrents">
<v-icon color="grey">play_arrow</v-icon>
</v-btn>
<v-btn small fab text class="mr-0 ml-0" @click="pauseTorrents">
<v-icon color="grey">pause</v-icon>
</v-btn>
</v-app-bar>
<!--navigation drawer itself -->
<v-navigation-drawer app v-model="drawer" class="primary" style="position:fixed;">
<!--current download speeds -->
<v-flex class="mt-3" v-if="stats">
<div
class="secondary_lighter--text text-uppercase caption ml-4"
>
current speed
</div>
<v-card color="secondary" flat class="mr-2 ml-2">
<v-layout row wrap class="pa-3 project nav_download mx-auto">
<v-icon color="download">keyboard_arrow_down</v-icon>
<span class="download--text title">
{{
stats.dlspeed.substring(
0,
stats.dlspeed.indexOf(' ')
)
}}
<span class="font-weight-light caption">{{
stats.dlspeed.substring(
stats.dlspeed.indexOf(' ')
)
}}</span>
</span>
<v-icon class="pl-5" color="upload"
>keyboard_arrow_up</v-icon
>
<span class="upload--text title">
{{
stats.upspeed.substring(
0,
stats.upspeed.indexOf(' ')
)
}}
<span class="font-weight-light caption">{{
stats.upspeed.substring(
stats.upspeed.indexOf(' ')
)
}}</span>
</span>
</v-layout>
</v-card>
<!--speeds graph -->
<div class="mt-4">
<apexcharts
ref="chart"
type="line"
:options="chartOptions"
:series="series"
></apexcharts>
</div>
<div class="mt-4"></div>
<div
class="secondary_lighter--text text-uppercase caption ml-4"
>
session stats
</div>
<v-card flat color="secondary" class="mr-2 ml-2">
<v-layout row wrap class="pa-3 project nav_download mx-auto">
<v-flex md6>
<div style="font-size: 0.95em; margin-top: 6px;" class="download--text">Downloaded</div>
</v-flex>
<v-flex md5 class="ml-4">
<span class="download--text title">
{{
stats.downloaded.substring(
0,
stats.downloaded.indexOf(' ')
)
}}
<span class="font-weight-light caption">{{
stats.downloaded.substring(
stats.downloaded.indexOf(' ')
)
}}</span>
</span>
</v-flex>
</v-layout>
</v-card>
<v-card flat color="secondary" class="ml-2 mr-2 mt-1">
<v-layout row wrap class="pa-3 project nav_upload mx-auto">
<v-flex md6>
<div style="font-size: 0.95em; margin-top: 6px;" class="upload--text">Uploaded</div>
</v-flex>
<v-flex md5 class="ml-4">
<span class="upload--text title">
{{
stats.uploaded.substring(
0,
stats.uploaded.indexOf(' ')
)
}}
<span class="font-weight-light caption">{{
stats.uploaded.substring(
stats.uploaded.indexOf(' ')
)
}}</span>
</span>
</v-flex>
</v-layout>
</v-card>
</v-flex>
<v-flex style="position:fixed; bottom: 15px; right: 15px;" >
<v-list>
<v-list-item @click="toggleTheme" link>
<v-icon v-if="theme === 'Light'" class="pr-2 white--text"
>brightness_7</v-icon
>
<v-icon v-else class="pr-2 white--text">brightness_2</v-icon>
<v-list-item-title class="white--text" style="font-size:15px">{{
theme
}}</v-list-item-title>
</v-list-item>
</v-list>
</v-flex>
</v-navigation-drawer>
</nav>
</template>
<script>
import { mapMutations, mapState, mapGetters } from 'vuex'
import { setInterval } from 'timers'
import VueApexCharts from 'vue-apexcharts'
import qbit from '@/services/qbit'
export default {
components: { apexcharts: VueApexCharts },
data() {
return {
drawer: false,
paused: false,
chartOptions: {
chart: {
sparkline: {
enabled: true
},
animations: {
enabled: false,
dynamicAnimation: {
speed: 2000
}
}
},
colors: ['#00b3fa', '#64CEAA'],
stroke: {
show: true,
curve: 'smooth',
lineCap: 'round',
width: 4
},
fill: {
type: 'gradient',
gradient: {
shade: 'dark',
type: 'vertical',
shadeIntensity: 0.5,
opacityFrom: 0.6,
opacityTo: 0.5,
stops: [0, 50, 100]
}
}
},
series: [
{
name: 'upload',
type: 'area',
data: this.$store.state.upload_data
},
{
name: 'download',
type: 'area',
data: this.$store.state.download_data
}
],
chartInterval: null
}
},
methods: {
...mapMutations(['REFRESH_TORRENTS', 'CLEAR_INTERVALS']),
pauseTorrents() {
qbit.pauseTorrents(this.selected_torrents)
},
resumeTorrents(){
qbit.resumeTorrents(this.selected_torrents)
},
removeTorrents() {
qbit.deleteTorrents(this.selected_torrents, false)
},
updateChart() {
this.$refs.chart.updateSeries(this.series, true)
},
toggleModal(name) {
this.$store.commit('TOGGLE_MODAL', name)
},
toggleTheme() {
this.$store.commit('TOGGLE_THEME')
this.$vuetify.theme.dark = !this.$vuetify.theme.dark;
},
},
computed: {
...mapState(['stats', 'selected_torrents']),
...mapGetters(['getTheme']),
theme() {
return this.getTheme() ? 'Dark' : 'Light'
}
},
created() {
this.chartInterval = setInterval(async () => {
this.updateChart()
}, 2000)
this.$vuetify.theme.dark = this.getTheme()
},
beforeDestroy() {
clearInterval(this.chartInterval)
}
}
</script>
<style>
.project.nav_upload {
border-left: 4px solid #00b3fa;
}
.project.nav_download {
border-left: 4px solid #64ceaa;
}
.allow-spacer {
display: flex;
flex-direction: column;
}
</style>
<template>
<nav>
<!--title-->
<v-app-bar app flat color="background">
<v-app-bar-nav-icon
@click.stop="drawer = !drawer"
class="grey--text text--lighten-1"
></v-app-bar-nav-icon>
<v-toolbar-title
:class="[
'grey--text',
{ 'subheading ml-0': $vuetify.breakpoint.smAndDown }
]"
>
<div v-if="!$vuetify.breakpoint.xs">
<span class="font-weight-light">Vue</span>
<span>Torrent</span>
</div>
</v-toolbar-title>
<v-spacer></v-spacer>
<!--right corner functions-->
<v-btn
text
small
fab
color="grey"
class="mr-0 ml-0"
@click="toggleModal('addmodal')"
>
<v-icon color="grey">add</v-icon>
</v-btn>
<v-btn small fab text class="mr-0 ml-0" @click="removeTorrents">
<v-icon color="grey">remove</v-icon>
</v-btn>
<v-btn small fab text class="mr-0 ml-0" @click="resumeTorrents">
<v-icon color="grey">play_arrow</v-icon>
</v-btn>
<v-btn small fab text class="mr-0 ml-0" @click="pauseTorrents">
<v-icon color="grey">pause</v-icon>
</v-btn>
<v-btn
small
fab
text
class="mr-0 ml-0"
@click="toggleModal('settingsmodal')"
>
<v-icon color="grey">settings</v-icon>
</v-btn>
</v-app-bar>
<!--navigation drawer itself -->
<v-navigation-drawer
app
v-model="drawer"
class="primary"
style="position: fixed;"
disable-resize-watcher
>
<!--current download speeds -->
<v-flex class="mt-3" v-if="status">
<div
class="secondary_lighter--text text-uppercase caption ml-4"
>
current speed
</div>
<v-card color="secondary" flat class="mr-2 ml-2">
<v-layout
row
wrap
class="pa-3 project nav_download mx-auto"
>
<v-icon color="download">keyboard_arrow_down</v-icon>
<span class="download--text title">
{{
status.dlspeed.substring(
0,
status.dlspeed.indexOf(' ')
)
}}
<span class="font-weight-light caption">{{
status.dlspeed.substring(
status.dlspeed.indexOf(' ')
)
}}</span>
</span>
<v-icon class="pl-5" color="upload"
>keyboard_arrow_up</v-icon
>
<span class="upload--text title">
{{
status.upspeed.substring(
0,
status.upspeed.indexOf(' ')
)
}}
<span class="font-weight-light caption">{{
status.upspeed.substring(
status.upspeed.indexOf(' ')
)
}}</span>
</span>
</v-layout>
</v-card>
<!--speeds graph -->
<div class="mt-4">
<apexcharts
ref="chart"
type="line"
:options="chartOptions"
:series="series"
></apexcharts>
</div>
<div class="mt-4"></div>
<div
class="secondary_lighter--text text-uppercase caption ml-4"
>
session status
</div>
<v-card flat color="secondary" class="mr-2 ml-2">
<v-layout
row
wrap
class="pa-3 project nav_download mx-auto"
>
<v-flex md6>
<div
style="font-size: 0.95em; margin-top: 6px;"
class="download--text"
>
Downloaded
</div>
</v-flex>
<v-flex md5 class="ml-4">
<span class="download--text title">
{{
status.downloaded.substring(
0,
status.downloaded.indexOf(' ')
)
}}
<span class="font-weight-light caption">{{
status.downloaded.substring(
status.downloaded.indexOf(' ')
)
}}</span>
</span>
</v-flex>
</v-layout>
</v-card>
<v-card flat color="secondary" class="ml-2 mr-2 mt-1">
<v-layout row wrap class="pa-3 project nav_upload mx-auto">
<v-flex md6>
<div
style="font-size: 0.95em; margin-top: 6px;"
class="upload--text"
>
Uploaded
</div>
</v-flex>
<v-flex md5 class="ml-4">
<span class="upload--text title">
{{
status.uploaded.substring(
0,
status.uploaded.indexOf(' ')
)
}}
<span class="font-weight-light caption">{{
status.uploaded.substring(
status.uploaded.indexOf(' ')
)
}}</span>
</span>
</v-flex>
</v-layout>
</v-card>
<v-card
v-if="webuiSettings.showFreeSpace"
flat
style="margin-top: 30px;"
color="secondary"
class="ml-2 mr-2"
>
<v-layout row wrap class="pa-3 project nav_upload mx-auto">
<v-flex md6>
<div
style="font-size: 0.95em; margin-top: 6px;"
class="upload--text"
>
Free Space
</div>
</v-flex>
<v-flex md5 class="ml-4">
<span class="upload--text title">
{{
status.freeDiskSpace.substring(
0,
status.freeDiskSpace.indexOf(' ')
)
}}
<span class="font-weight-light caption">{{
status.freeDiskSpace.substring(
status.freeDiskSpace.indexOf(' ')
)
}}</span>
</span>
</v-flex>
</v-layout>
</v-card>
</v-flex>
<v-container>
<v-row justify="space-between">
<v-col>
<v-tooltip top>
<template v-slot:activator="{ on }">
<v-list-item v-on="on" @click="logout" link>
<v-icon class="white--text"
>exit_to_app</v-icon
>
</v-list-item>
</template>
<span>Log out</span>
</v-tooltip>
</v-col>
<v-col>
<v-tooltip top>
<template v-slot:activator="{ on }">
<v-list-item
v-on="on"
@click="toggleSpeed"
link
>
<v-icon
:color="altSpeed ? 'download' : ''"
class="white--text"
>speed</v-icon
>
</v-list-item>
</template>
<span>Alt speeds</span>
</v-tooltip>
</v-col>
<v-col>
<v-tooltip top>
<template v-slot:activator="{ on }">
<v-list-item
v-on="on"
@click="toggleTheme"
link
>
<v-icon
v-if="theme === 'Light'"
class="white--text"
>brightness_7</v-icon
>
<v-icon v-else class="pr-2 white--text"
>brightness_2</v-icon
>
</v-list-item>
</template>
<span>{{ theme }}</span>
</v-tooltip>
</v-col>
</v-row>
</v-container>
</v-navigation-drawer>
</nav>
</template>
<script>
import { mapMutations, mapState, mapGetters } from 'vuex'
import VueApexCharts from 'vue-apexcharts'
import qbit from '@/services/qbit'
export default {
components: { apexcharts: VueApexCharts },
data() {
return {
drawer: false,
paused: false,
chartOptions: {
chart: {
sparkline: {
enabled: true
},
animations: {
enabled: false,
dynamicAnimation: {
speed: 1000
}
}
},
colors: ['#00b3fa', '#64CEAA'],
stroke: {
show: true,
curve: 'smooth',
lineCap: 'round',
width: 4
},
fill: {
type: 'gradient',
gradient: {
shade: 'dark',
type: 'vertical',
shadeIntensity: 0.5,
opacityFrom: 0.6,
opacityTo: 0.5,
stops: [0, 50, 100]
}
},
tooltip: {
theme: 'light'
}
},
chartInterval: null
}
},
methods: {
...mapMutations(['REFRESH_TORRENTS', 'CLEAR_INTERVALS']),
pauseTorrents() {
qbit.pauseTorrents(this.selected_torrents)
},
resumeTorrents() {
qbit.resumeTorrents(this.selected_torrents)
},
removeTorrents() {
qbit.deleteTorrents(this.selected_torrents, false)
},
updateChart() {
this.$refs.chart.updateSeries(this.series, true)
},
toggleModal(name) {
this.$store.commit('TOGGLE_MODAL', name)
},
toggleTheme() {
this.$store.commit('TOGGLE_THEME')
this.$vuetify.theme.dark = !this.$vuetify.theme.dark
},
logout() {
this.$store.commit('LOGOUT')
this.$router.push('/login')
},
toggleSpeed() {
qbit.toggleSpeedLimitsMode()
},
setChartTooltipTheme(theme) {
this.chartOptions.tooltip.theme = theme.toLowerCase()
this.$refs.chart.updateOptions(this.chartOptions)
}
},
computed: {
...mapState(['status', 'selected_torrents']),
...mapGetters(['getTheme', 'getStatus', 'getWebuiSettings']),
theme() {
return this.getTheme() ? 'Dark' : 'Light'
},
altSpeed() {
return this.getStatus().altSpeed
},
series() {
return [
{
name: 'upload',
type: 'area',
data: this.$store.state.upload_data
},
{
name: 'download',
type: 'area',
data: this.$store.state.download_data
}
]
},
webuiSettings() {
return this.getWebuiSettings()
}
},
created() {
this.$vuetify.theme.dark = this.getTheme()
},
mounted() {
this.setChartTooltipTheme(this.theme)
},
watch: {
theme(newValue) {
this.setChartTooltipTheme(newValue)
}
}
}
</script>
<style lang="scss" scoped>
.justify-space-between {
position: fixed;
bottom: 0px;
width: 100%;
}
</style>
<style>
.project.nav_upload {
border-left: 4px solid #00b3fa;
}
.project.nav_download {
border-left: 4px solid #64ceaa;
}
.allow-spacer {
display: flex;
flex-direction: column;
}
</style>

View file

@ -1,171 +1,209 @@
<template>
<v-card ripple
flat class="pointer torrent"
:class="
containsTorrent(torrent.hash) ? 'torrent_selected' : ''
"
@click.native="selectTorrent(torrent.hash)">
<v-layout row wrap :class="`pa-4 ml-0 project ${torrent.state}`">
<v-flex xs12 sm2 md3>
<div class="caption grey--text">Torrent title</div>
<div>{{ torrent.name }}</div>
</v-flex>
<v-flex xs6 sm1 md1 class="mr-2">
<div class="caption grey--text">Size</div>
<div>
{{
torrent.size.substring(
0,
torrent.size.indexOf(' ')
)
}}
<span class="caption grey--text">{{
torrent.size.substring(
torrent.size.indexOf(' ')
)
}}</span>
</div>
</v-flex>
<v-flex xs5 sm1 md1 class="mr-2">
<div class="caption grey--text">Done</div>
<div>
{{
torrent.dloaded.substring(
0,
torrent.dloaded.indexOf(' ')
)
}}
<span class="caption grey--text">{{
torrent.dloaded.substring(
torrent.dloaded.indexOf(' ')
)
}}</span>
</div>
</v-flex>
<v-flex xs6 sm1 md1 class="mr-2">
<div class="caption grey--text">Download</div>
<div>
{{
torrent.dlspeed.substring(
0,
torrent.dlspeed.indexOf(' ')
)
}}
<span class="caption grey--text">{{
torrent.dlspeed.substring(
torrent.dlspeed.indexOf(' ')
)
}}</span>
</div>
</v-flex>
<v-flex xs5 sm1 md1 class="mr-2">
<div class="caption grey--text">Upload</div>
<div>
{{
torrent.upspeed.substring(
0,
torrent.upspeed.indexOf(' ')
)
}}
<span class="caption grey--text">{{
torrent.upspeed.substring(
torrent.upspeed.indexOf(' ')
)
}}</span>
</div>
</v-flex>
<v-flex xs6 sm1 md1 class="mr-2">
<div class="caption grey--text">ETA</div>
<div>{{ torrent.eta }}</div>
</v-flex>
<v-flex xs5 sm1 md1 class="mr-2">
<div class="caption grey--text">Peers</div>
<div>
{{ torrent.num_leechs }}
<span class="grey--text caption"
>/{{ torrent.available_peers }}</span
>
</div>
</v-flex>
<v-flex xs5 sm1 md1 class="mr-2">
<div class="caption grey--text">Seeds</div>
<div>
{{ torrent.num_seeds }}
<span class="grey--text caption"
>/{{ torrent.available_seeds }}</span
>
</div>
</v-flex>
<v-flex xs4 sm12 md1>
<div class="right">
<v-chip
small
:class="
`${torrent.state} white--text my-2 caption`
"
>{{ torrent.state }}</v-chip
>
</div>
</v-flex>
<v-flex xs12 sm12 md12>
<v-progress-linear
height="3"
color="cyan darken-1"
background-color="cyan lighten-3"
:value="(torrent.dloaded / torrent.size) * 100"
></v-progress-linear>
</v-flex>
</v-layout>
<v-divider></v-divider>
</v-card>
</template>
<script>
export default {
name:'Torrent',
props: {
torrent: Object
},
methods: {selectTorrent(hash) {
if (this.containsTorrent(hash)) {
this.$store.commit('SET_SELECTED', {type:"remove", hash})
} else {
this.$store.commit('SET_SELECTED', {type:"add", hash})
}
},
containsTorrent(hash) {
return this.$store.getters.containsTorrent(hash)
},}
}
</script>
<style>
.project.done {
border-left: 4px solid #3cd1c2;
}
.project.busy {
border-left: 4px solid #ffaa2c;
}
.project.fail {
border-left: 4px solid #f83e70;
}
.project.paused {
border-left: 4px solid #cfd8dc;
}
.v-chip.done {
background: #3cd1c2 !important;
}
.v-chip.busy {
background: #ffaa2c !important;
}
.v-chip.fail {
background: #f83e70 !important;
}
.v-chip.paused {
background: #cfd8dc !important;
}
.pointer {
cursor: pointer;
}
</style>
<template>
<v-card
ripple
flat
class="pointer torrent"
:class="containsTorrent(torrent.hash) ? 'torrent_selected' : ''"
@click.native="selectTorrent(torrent.hash)"
@dblclick.prevent="showInfo(torrent.hash)"
>
<v-tooltip top>
<template v-slot:activator="{ on }">
<v-layout
@contextmenu.prevent="$refs.menu.open"
v-on="on"
row
wrap
:class="`pa-4 ml-0 project ${torrent.state}`"
>
<v-flex xs12 sm2 md3>
<div class="caption grey--text">Torrent title</div>
<div class="truncate">{{ torrent.name }}</div>
</v-flex>
<v-flex xs6 sm1 md1 class="mr-2">
<div class="caption grey--text">Size</div>
<div>
{{
torrent.size.substring(
0,
torrent.size.indexOf(' ')
)
}}
<span class="caption grey--text">{{
torrent.size.substring(
torrent.size.indexOf(' ')
)
}}</span>
</div>
</v-flex>
<v-flex xs5 sm1 md1 class="mr-2">
<div class="caption grey--text">Done</div>
<div>
{{
torrent.dloaded.substring(
0,
torrent.dloaded.indexOf(' ')
)
}}
<span class="caption grey--text">{{
torrent.dloaded.substring(
torrent.dloaded.indexOf(' ')
)
}}</span>
</div>
</v-flex>
<v-flex xs6 sm1 md1 class="mr-2">
<div class="caption grey--text">Download</div>
<div>
{{
torrent.dlspeed.substring(
0,
torrent.dlspeed.indexOf(' ')
)
}}
<span class="caption grey--text">{{
torrent.dlspeed.substring(
torrent.dlspeed.indexOf(' ')
)
}}</span>
</div>
</v-flex>
<v-flex xs5 sm1 md1 class="mr-2">
<div class="caption grey--text">Upload</div>
<div>
{{
torrent.upspeed.substring(
0,
torrent.upspeed.indexOf(' ')
)
}}
<span class="caption grey--text">{{
torrent.upspeed.substring(
torrent.upspeed.indexOf(' ')
)
}}</span>
</div>
</v-flex>
<v-flex xs6 sm1 md1 class="mr-2">
<div class="caption grey--text">ETA</div>
<div>{{ torrent.eta }}</div>
</v-flex>
<v-flex xs5 sm1 md1 class="mr-2">
<div class="caption grey--text">Peers</div>
<div>
{{ torrent.num_leechs }}
<span class="grey--text caption"
>/{{ torrent.available_peers }}</span
>
</div>
</v-flex>
<v-flex xs5 sm1 md1 class="mr-2">
<div class="caption grey--text">Seeds</div>
<div>
{{ torrent.num_seeds }}
<span class="grey--text caption"
>/{{ torrent.available_seeds }}</span
>
</div>
</v-flex>
<v-flex xs4 sm12 md1>
<div class="right">
<v-chip
small
:class="`${torrent.state} white--text my-2 caption`"
>{{ torrent.state }}</v-chip
>
</div>
</v-flex>
<!-- labels -->
<v-flex v-for="tag in torrent.tags" :key="tag" xs3 sm1 md1>
<v-chip small class="download white--text my-2 caption">
{{ tag }}
</v-chip>
</v-flex>
<v-flex xs12 sm12 md12>
<v-progress-linear
height="3"
rounded
color="cyan darken-1"
background-color="cyan lighten-3"
:value="torrent.progress"
></v-progress-linear>
</v-flex>
</v-layout>
</template>
<span>{{ torrent.name }}</span>
</v-tooltip>
<v-divider></v-divider>
<vue-context ref="menu">
<TorrentRightClickMenu :hash="torrent.hash" />
</vue-context>
</v-card>
</template>
<script>
import { VueContext } from 'vue-context'
import TorrentRightClickMenu from '@/components/Torrent/TorrentRightClickMenu.vue'
export default {
name: 'Torrent',
components: {
VueContext,
TorrentRightClickMenu
},
props: {
torrent: Object
},
methods: {
selectTorrent(hash) {
if (this.containsTorrent(hash)) {
this.$store.commit('SET_SELECTED', { type: 'remove', hash })
} else {
this.$store.commit('SET_SELECTED', { type: 'add', hash })
}
},
containsTorrent(hash) {
return this.$store.getters.containsTorrent(hash)
},
showInfo(hash) {
this.$store.commit('TOGGLE_MODAL', 'TorrentDetailModal')
this.$store.commit('SET_SELECTED_TORRENT_DETAIL', hash)
}
}
}
</script>
<style>
.project.done {
border-left: 4px solid #3cd1c2;
}
.project.busy {
border-left: 4px solid #ffaa2c;
}
.project.fail {
border-left: 4px solid #f83e70;
}
.project.paused {
border-left: 4px solid #cfd8dc;
}
.v-chip.done {
background: #3cd1c2 !important;
}
.v-chip.busy {
background: #ffaa2c !important;
}
.v-chip.fail {
background: #f83e70 !important;
}
.v-chip.paused {
background: #cfd8dc !important;
}
.pointer {
cursor: pointer;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -0,0 +1,98 @@
<template>
<v-card
elevation="20"
width="200"
style="
position: absolute;
top: 50%;
left: 50%;
z-index: 10;
overflow: show;
"
:dark="dark"
>
<v-list dense rounded>
<v-list-item @click="showInfo" link>
<v-icon>info</v-icon>
<v-list-item-title class="ml-2" style="font-size: 15px;"
>Show Info</v-list-item-title
>
</v-list-item>
<v-divider />
<v-list-item @click="resume" link>
<v-icon>play_arrow</v-icon>
<v-list-item-title class="ml-2" style="font-size: 15px;"
>Resume</v-list-item-title
>
</v-list-item>
<v-list-item @click="pause" link>
<v-icon>pause</v-icon>
<v-list-item-title class="ml-2" style="font-size: 15px;"
>Pause</v-list-item-title
>
</v-list-item>
<v-divider />
<v-list-item @click="reannounce" link>
<v-icon>record_voice_over</v-icon>
<v-list-item-title class="ml-2" style="font-size: 15px;"
>reannounce</v-list-item-title
>
</v-list-item>
<v-divider />
<v-list-item @click="deleteWithoutFiles" link>
<v-icon color="red">delete</v-icon>
<v-list-item-title
class="ml-2"
style="font-size: 15px; color: red;"
>Delete</v-list-item-title
>
</v-list-item>
<v-list-item @click="deleteWithFiles" link>
<v-icon color="red">delete</v-icon>
<v-list-item-title
class="ml-2"
style="font-size: 15px; color: red;"
>Delete with files</v-list-item-title
>
</v-list-item>
</v-list>
</v-card>
</template>
<script>
import qbit from '@/services/qbit'
export default {
name: 'TorrentRightClickMenu',
props: {
hash: String
},
methods: {
resume() {
qbit.resumeTorrents([this.hash])
},
pause() {
qbit.pauseTorrents([this.hash])
},
reannounce() {
qbit.reannounceTorrents([this.hash])
},
deleteWithoutFiles() {
qbit.deleteTorrents([this.hash], false)
},
deleteWithFiles() {
qbit.deleteTorrents([this.hash], true)
},
showInfo() {
this.$store.commit('TOGGLE_MODAL', 'TorrentDetailModal')
this.$store.commit('SET_SELECTED_TORRENT_DETAIL', this.hash)
}
},
computed: {
dark() {
return this.$vuetify.dark
}
}
}
</script>
<style></style>

View file

@ -0,0 +1,72 @@
<template>
<v-card flat>
<perfect-scrollbar>
<v-card-text style="max-height: 500px; min-height: 400px;">
<v-treeview
v-model="tree"
:items="fileTree"
activatable
item-key="name"
open-on-click
>
<template v-slot:prepend="{ item, open }">
<v-icon v-if="!item.icon">
{{ open ? 'mdi-folder-open' : 'mdi-folder' }}
</v-icon>
<v-icon v-else>{{ item.icon }}</v-icon>
</template>
<template v-slot:append="{ item }">
<span v-if="!item.icon"
>{{ item.children.length }} Files</span
>
<div v-else>
<span>[{{ item.size }}]</span>
<span class="ml-4">{{ item.progress }}%</span>
</div>
</template>
</v-treeview>
</v-card-text>
</perfect-scrollbar>
</v-card>
</template>
<script>
import qbit from '@/services/qbit'
import { treeify } from '@/helpers'
export default {
name: 'Content',
props: {
hash: String,
isActive: Boolean
},
data() {
return {
tree: [],
treeData: null
}
},
computed: {
fileTree() {
if (this.treeData) {
return treeify(this.treeData)
}
return []
}
},
methods: {
async getTorrentFiles() {
const { data } = await qbit.getTorrentFiles(this.hash)
this.treeData = data
}
},
watch: {
isActive(active) {
if (active) {
this.getTorrentFiles()
}
}
}
}
</script>
<style></style>

View file

@ -0,0 +1,68 @@
<template>
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-container
style="min-height: 200px;"
:class="`pa-0 project done`"
>
<v-card-title class="justify-center">
<h2>Create New Tag</h2>
</v-card-title>
<v-form class="px-6 mt-3">
<v-container>
<v-text-field
class="mx-auto"
style="max-width: 200px;"
v-model="tagname"
:rules="rules"
:counter="10"
label="Tag name"
required
></v-text-field>
</v-container>
</v-form>
</v-container>
<v-card-actions class="justify-center pb-5 project done">
<v-btn text @click="cancel" class="error white--text mt-3"
>Cancel</v-btn
>
<v-btn
text
@click="create"
class="green_accent white--text mt-3"
>Save</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import qbit from '@/services/qbit'
export default {
name: 'createNewTagDialog',
props: {
dialog: Boolean
},
data: () => ({
tagname: '',
rules: [
v => !!v || 'Tag is required',
v => v.length <= 10 || 'Tag must be less than 10 characters'
]
}),
methods: {
create() {
qbit.createTag(this.tagname)
this.cancel()
},
cancel() {
this.tagname = ''
this.$emit('close')
}
}
}
</script>
<style></style>

View file

@ -0,0 +1,59 @@
<template>
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-container
style="min-height: 200px;"
:class="`pa-0 project done`"
>
<v-card-title class="justify-center">
<h2>Delete Tag</h2>
</v-card-title>
<v-list
rounded
v-if="tags"
class="text-center mx-auto"
style="max-width: 200px;"
>
<v-list-item
@click="deleteTag(t)"
v-for="(t, i) in tags"
:key="i"
>
<v-list-item-content>
<v-list-item-title v-text="t"></v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-container>
<v-card-actions class="justify-center pb-5 project done">
<v-btn text @click="cancel" class="error white--text mt-3"
>Close</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import qbit from '@/services/qbit'
export default {
name: 'DeleteTagDialog',
props: {
dialog: Boolean,
tags: Array
},
methods: {
deleteTag(tag) {
qbit.deleteTag(tag)
this.cancel()
},
cancel() {
this.tagname = ''
this.$emit('close')
}
}
}
</script>
<style></style>

View file

@ -0,0 +1,135 @@
<template>
<v-card flat>
<v-card-text class="pa-0" style="font-size: 1.1em;">
<v-simple-table>
<tbody>
<tr>
<td class="grey--text">Torrent title</td>
<td class="torrentmodaltext--text">
{{ torrent.name }}
</td>
</tr>
<tr style="margin-top: 10px !important;">
<td class="grey--text">hash</td>
<td class="torrentmodaltext--text">
{{ torrent.hash }}
</td>
</tr>
<tr>
<td class="grey--text">Size</td>
<td class="torrentmodaltext--text">
{{ torrent.size }}
</td>
</tr>
<tr>
<td class="grey--text">Done:</td>
<td class="torrentmodaltext--text">
{{ torrent.dloaded }}
</td>
</tr>
<tr>
<td class="grey--text">Download</td>
<td class="torrentmodaltext--text">
{{ torrent.dlspeed }}
</td>
</tr>
<tr>
<td class="grey--text">Upload</td>
<td class="torrentmodaltext--text">
{{ torrent.upspeed }}
</td>
</tr>
<tr>
<td class="grey--text">ETA</td>
<td class="torrentmodaltext--text">
{{ torrent.eta }}
</td>
</tr>
<tr>
<td class="grey--text">Peers</td>
<td class="torrentmodaltext--text">
{{ torrent.num_leechs
}}<span class="grey--text"
>/{{ torrent.available_peers }}</span
>
</td>
</tr>
<tr>
<td class="grey--text">Seeds</td>
<td class="torrentmodaltext--text">
{{ torrent.num_seeds
}}<span class="grey--text"
>/{{ torrent.available_seeds }}</span
>
</td>
</tr>
<tr>
<td class="grey--text">Ratio</td>
<td class="torrentmodaltext--text">
{{ torrent.ratio }}%
</td>
</tr>
<tr>
<td class="grey--text">Tags</td>
<td v-if="torrent.tags">
{{ torrent.tags.join(',') }}
</td>
<td v-else>None</td>
</tr>
<tr>
<td class="grey--text">Status</td>
<v-chip
small
:class="`${torrent.state} white--text my-2 caption`"
>{{ torrent.state }}</v-chip
>
</tr>
</tbody>
</v-simple-table>
<v-flex class="pt-3 pb-4">
<v-progress-linear
height="5"
stream
rounded
color="cyan darken-1"
background-color="cyan lighten-3"
:buffer-value="torrent.progress"
></v-progress-linear>
</v-flex>
</v-card-text>
</v-card>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Info',
props: {
hash: String
},
computed: {
...mapGetters(['getTorrent']),
torrent() {
return this.getTorrent(this.hash)
}
}
}
</script>
<style lang="scss" scoped>
::v-deep .v-data-table thead th,
::v-deep .v-data-table tbody td {
padding: 0 !important;
height: 3em;
white-space: nowrap;
&:first-child {
padding: 0 0 0 8px !important;
}
&:last-child {
padding-right: 8px !important;
}
}
</style>

View file

@ -0,0 +1,149 @@
<template>
<perfect-scrollbar>
<v-data-table
v-if="peers"
:headers="headers"
:items="peers"
:items-per-page="-1"
:hide-default-footer="true"
style="max-height: 500px; min-height: 400px;"
>
<template v-slot:item="row">
<tr>
<td class="ip">
<template v-if="row.item.country_code">
<img
v-if="isWindows"
class="country-flag"
:title="row.item.country"
:alt="codeToFlag(row.item.country_code).char"
:src="codeToFlag(row.item.country_code).url"
/>
<template v-else>{{
codeToFlag(row.item.country_code).char
}}</template>
</template>
{{ row.item.ip }}
<span class="grey--text">:{{ row.item.port }}</span>
</td>
<td>{{ row.item.connection }}</td>
<td :title="row.item.flags_desc">{{ row.item.flags }}</td>
<td>{{ row.item.client }}</td>
<td>{{ row.item.progress | progress }}</td>
<td>{{ row.item.dl_speed | networkSpeed }}</td>
<td>{{ row.item.downloaded | networkSize }}</td>
<td>{{ row.item.up_speed | networkSpeed }}</td>
<td>{{ row.item.uploaded | networkSize }}</td>
<td>{{ row.item.relevance | progress }}</td>
<td>{{ row.item.files }}</td>
</tr>
</template>
</v-data-table>
</perfect-scrollbar>
</template>
<script>
import { map, merge, cloneDeep } from 'lodash'
import qbit from '@/services/qbit'
// eslint-disable-next-line no-unused-vars
import { codeToFlag, isWindows } from '@/helpers'
export default {
name: 'Peers',
props: { hash: String, isActive: Boolean },
data: () => ({
headers: [
{ text: 'IP', value: 'ip' },
{ text: 'Connection', value: 'connection' },
{ text: 'Flags', value: 'flags' },
{ text: 'Client', value: 'client' },
{ text: 'Progress', value: 'progress' },
{ text: 'DL Speed', value: 'dl_speed' },
{ text: 'Downloaded', value: 'downloaded' },
{ text: 'UP Speed', value: 'up_speed' },
{ text: 'Uploaded', value: 'uploaded' },
{ text: 'Relevance', value: 'relevance' },
{ text: 'Files', value: 'files' }
],
peersObj: null
}),
methods: {
codeToFlag(val) {
return codeToFlag(val)
},
isWindows() {
return isWindows()
},
async getTorrentPeers() {
const { data } = await qbit.getTorrentPeers(
this.hash,
this.rid || undefined
)
this.rid = data.rid
if (data.full_update) {
this.peersObj = data.peers
} else {
const tmp = cloneDeep(this.peersObj)
if (data.peers_removed) {
for (const key of data.peers_removed) {
delete tmp[key]
}
}
this.peersObj = merge(tmp, data.peers)
}
}
},
watch: {
isActive(active) {
if (active) {
this.getTorrentPeers()
}
}
},
computed: {
rid: {
get() {
return this.$store.state.rid
},
set(val) {
this.$store.state.rid = val
}
},
peers() {
return map(this.peersObj, (value, key) => merge({}, value, { key }))
}
}
}
</script>
<style scoped>
::v-deep .ip {
display: flex;
align-items: center;
}
::v-deep .ip .country-flag {
width: 1.5em;
margin-right: 0.5em;
}
</style>
<style lang="scss" scoped>
@import '~@/assets/styles.scss';
::v-deep .v-data-table thead th,
::v-deep .v-data-table tbody td {
padding: 0 2px !important;
height: auto;
white-space: nowrap;
&:first-child {
padding: 0 0 0 8px !important;
}
&:last-child {
padding-right: 8px !important;
}
}
</style>

View file

@ -0,0 +1,105 @@
<template>
<v-card flat>
<v-card-text
class="mx-auto mt-5"
style="font-size: 1.1em; max-height: 500px; min-height: 300px;"
>
<v-layout class="mx-auto" row wrap>
<v-flex xs12 sm12>
<h3>Available Tags:</h3>
</v-flex>
<v-flex xs12 sm12 class="mt-3">
<v-chip
v-for="tag in availableTags"
:key="tag"
small
class="download white--text caption mx-2"
style="font-size: 0.95em !important;"
@click="addTag(tag)"
>
{{ tag }}
</v-chip>
</v-flex>
</v-layout>
<v-layout class="mx-auto mt-12" row wrap>
<v-flex xs12 sm12>
<h3>Current Tags:</h3>
</v-flex>
<v-flex xs12 sm12 class="mt-3">
<v-chip
v-for="tag in torrent.tags"
:key="tag"
small
close
class="download white--text caption mx-2"
style="font-size: 0.95em !important;"
@click="deleteTag(tag)"
@click:close="deleteTag(tag)"
>{{ tag }}</v-chip
>
</v-flex>
</v-layout>
</v-card-text>
<v-card-actions class="justify-center pb-5">
<v-btn
text
class="error white--text mt-3"
@click="DeleteDialog = true"
>Delete</v-btn
>
<v-btn
text
class="green_accent white--text mt-3"
@click="CreateNewDialog = true"
>Create new</v-btn
>
</v-card-actions>
<CreateNewTagDialog
:dialog="CreateNewDialog"
@close="CreateNewDialog = false"
/>
<DeleteTagDialog
:dialog="DeleteDialog"
@close="DeleteDialog = false"
:tags="availableTags"
/>
</v-card>
</template>
<script>
import { difference } from 'lodash'
import { mapGetters } from 'vuex'
import qbit from '@/services/qbit'
import CreateNewTagDialog from './CreateNewTagDialog'
import DeleteTagDialog from './DeleteTagDialog'
export default {
name: 'Tags',
components: { CreateNewTagDialog, DeleteTagDialog },
props: {
hash: String
},
data: () => ({
CreateNewDialog: false,
DeleteDialog: false
}),
computed: {
...mapGetters(['getTorrent', 'getAvailableTags']),
torrent() {
return this.getTorrent(this.hash)
},
availableTags() {
let availableTags = this.getAvailableTags()
let currentTags = this.getTorrent(this.hash).tags
return difference(availableTags, currentTags)
}
},
methods: {
addTag(tag) {
qbit.addTorrentTag(this.hash, tag)
},
deleteTag(tag) {
qbit.removeTorrentTag(this.hash, tag)
}
}
}
</script>

View file

@ -0,0 +1,106 @@
<template>
<v-dialog
v-model="dialog"
scrollable
:width="dialogWidth"
:fullscreen="phoneLayout"
>
<v-card
v-if="torrent"
style="min-height: 400px; overflow: hidden !important;"
>
<div
:class="`pa-0 project ${torrent.state}`"
:style="{ height: phoneLayout ? '100vh' : '' }"
>
<v-card-title class="pb-0 justify-center primary">
<h2 class="white--text">Torrent Detail</h2>
</v-card-title>
<v-tabs v-model="tab" background-color="primary" center-active>
<v-tab href="#info">Info</v-tab>
<v-tab href="#trackers">Trackers</v-tab>
<v-tab href="#peers">Peers</v-tab>
<v-tab href="#content">Content</v-tab>
<v-tab href="#tags">Tags</v-tab>
</v-tabs>
<v-tabs-items v-model="tab" touchless>
<v-tab-item value="info">
<info :is-active="tab === 'info'" :hash="hash" />
</v-tab-item>
<v-tab-item value="peers">
<Peers :is-active="tab === 'peers'" :hash="hash" />
</v-tab-item>
<v-tab-item value="trackers">
<Trackers
:is-active="tab === 'trackers'"
:hash="hash"
/>
</v-tab-item>
<v-tab-item value="content">
<Content :is-active="tab === 'content'" :hash="hash" />
</v-tab-item>
<v-tab-item value="tags">
<Tags :is-active="tab === 'tags'" :hash="hash" />
</v-tab-item>
</v-tabs-items>
</div>
<v-fab-transition v-if="phoneLayout">
<v-btn @click="close" color="red" dark absolute bottom right>
<v-icon>close</v-icon>
</v-btn>
</v-fab-transition>
</v-card>
</v-dialog>
</template>
<script>
/* eslint-disable vue/no-unused-components */
import Modal from '@/mixins/Modal'
import { mapGetters } from 'vuex'
import Content from '@/components/TorrentDetailModal/Content'
import Info from '@/components/TorrentDetailModal/Info'
import Peers from '@/components/TorrentDetailModal/Peers'
import Trackers from '@/components/TorrentDetailModal/Trackers'
import Tags from '@/components/TorrentDetailModal/Tags'
export default {
name: 'TorrentDetailModal',
mixins: [Modal],
components: { Content, Info, Peers, Trackers,Tags },
data() {
return {
tab: null,
items: [{ tab: 'Info' }, { tab: 'Content' }],
peers: []
}
},
methods: {
close() {
this.$store.commit('TOGGLE_MODAL', 'TorrentDetailModal')
}
},
computed: {
...mapGetters(['getTorrent']),
hash() {
return this.$store.state.selectedDetailTorrent
},
torrent() {
return this.getTorrent(this.hash)
},
phoneLayout() {
return this.$vuetify.breakpoint.xsOnly
},
dialogWidth() {
return this.phoneLayout ? '100%' : '80%'
}
},
watch: {
dialog(visible) {
if (!visible) {
this.tab = null
}
}
}
}
</script>

View file

@ -0,0 +1,102 @@
<template>
<perfect-scrollbar>
<v-data-table
v-if="trackers"
:headers="headers"
:items="trackers"
:hide-default-footer="true"
style="max-height: 500px; min-height: 400px;"
>
<template v-slot:item="row">
<tr>
<td>{{ row.item.tier }}</td>
<td>{{ row.item.url }}</td>
<td>{{ row.item.status | formatTrackerStatus }}</td>
<td>{{ row.item.num_peers | formatTrackerNum }}</td>
<td>{{ row.item.num_seeds | formatTrackerNum }}</td>
<td>{{ row.item.num_leeches | formatTrackerNum }}</td>
<td>{{ row.item.num_downloaded | formatTrackerNum }}</td>
<td>{{ row.item.msg }}</td>
</tr>
</template>
</v-data-table>
</perfect-scrollbar>
</template>
<script>
import qbit from '@/services/qbit'
export default {
name: 'Trackers',
props: { hash: String, isActive: Boolean },
data: () => ({
headers: [
{ text: '#', value: 'tier' },
{ text: 'URL', value: 'url' },
{ text: 'Status', value: 'status' },
{ text: 'Peers', value: 'num_peers' },
{ text: 'Seeds', value: 'num_seeds' },
{ text: 'Leeches', value: 'num_leeches' },
{ text: 'Downloaded', value: 'num_downloaded' },
{ text: 'Message', value: 'msg' }
],
tempTrackers: []
}),
methods: {
async getTorrentTrackers() {
const { data } = await qbit.getTorrentTrackers(this.hash)
this.tempTrackers = data
}
},
watch: {
isActive(active) {
if (active) {
this.getTorrentTrackers()
}
}
},
filters: {
formatTrackerStatus(status) {
const map = [
'Disabled',
'Not contacted',
'Working',
'Updating',
'Not working'
]
return map[status]
},
formatTrackerNum(num) {
if (num === -1) {
return 'N/A'
}
return num.toString()
}
},
computed: {
trackers() {
return this.tempTrackers
}
}
}
</script>
<style lang="scss" scoped>
@import '~@/assets/styles.scss';
::v-deep .v-data-table thead th,
::v-deep .v-data-table tbody td {
padding: 0 2px !important;
height: auto;
white-space: nowrap;
&:first-child {
padding: 0 0 0 8px !important;
}
&:last-child {
padding-right: 8px !important;
}
}
</style>

83
src/filters.js Normal file
View file

@ -0,0 +1,83 @@
import dayjs from 'dayjs'
import Vue from 'vue'
/* eslint-disable no-param-reassign */
export function toPrecision(value, precision) {
if (value >= 10 ** precision) {
return value.toString()
}
if (value >= 1) {
return value.toPrecision(precision)
}
return value.toFixed(precision - 1)
}
export function formatSize(value) {
const units = 'KMGTP'
let index = -1
while (value >= 1000) {
value /= 1024
index++
}
const unit = index < 0 ? 'B' : `${units[index]}iB`
if (index < 0) {
return `${value} ${unit}`
}
return `${toPrecision(value, 3)} ${unit}`
}
Vue.filter('formatSize', formatSize)
Vue.filter('size', formatSize)
export function formatTimestamp(timestamp) {
if (timestamp == null || timestamp === -1) {
return ''
}
const m = dayjs.unix(timestamp)
return m.format('YYYY-MM-DD HH:mm:ss')
}
Vue.filter('formatTimestamp', formatTimestamp)
export function formatProgress(progress) {
// eslint-disable-next-line
progress *= 100
return `${toPrecision(progress, 3)}%`
}
Vue.filter('progress', formatProgress)
export function parseDate(str) {
if (!str) {
return null
}
return Date.parse(str) / 1000
}
Vue.filter('parseDate', parseDate)
export function formatNetworkSpeed(speed) {
if (speed === 0) {
return null
}
return `${formatSize(speed)}/s`
}
Vue.filter('networkSpeed', formatNetworkSpeed)
export function networkSize(size) {
if (size === 0) {
return null
}
return formatSize(size)
}
Vue.filter('networkSize', networkSize)

98
src/helpers.js Normal file
View file

@ -0,0 +1,98 @@
/* eslint-disable no-unused-vars */
export function formatBytes(a, b) {
if (a == 0) return '0 Bytes'
const c = 1024
const d = b || 2
const e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const f = Math.floor(Math.log(a) / Math.log(c))
return `${parseFloat((a / Math.pow(c, f)).toFixed(d))} ${e[f]}`
}
export function getIconForFileType(type) {
let types = {
html: 'mdi-language-html5',
js: 'mdi-nodejs',
json: 'mdi-json',
md: 'mdi-markdown',
pdf: 'mdi-file-pdf',
png: 'mdi-file-image',
txt: 'mdi-file-document-outline',
sub: 'mdi-file-document-outline',
idx: 'mdi-file-document-outline',
xls: 'mdi-file-excel',
avi: 'movie',
mp4: 'movie',
mkv: 'movie'
}
if (!types[type]) return 'insert_drive_file'
return types[type]
}
export const isWindows = navigator.userAgent.includes('Windows')
export function codeToFlag(code) {
const magicNumber = 0x1f1a5
// eslint-disable-next-line
code = code.toUpperCase()
const codePoints = [...code].map(c => magicNumber + c.charCodeAt(0))
const char = String.fromCodePoint(...codePoints)
const url =
'https://cdn.jsdelivr.net/npm/twemoji/2/svg/' +
`${codePoints[0].toString(16)}-${codePoints[1].toString(16)}.svg`
return {
char,
url
}
}
export function treeify(paths) {
let result = []
let level = { result }
paths.forEach(path => {
path.name.split('/').reduce((r, name, i, a) => {
if (!r[name]) {
r[name] = { result: [] }
r.result.push(createFile(path, name, r[name].result))
}
return r[name]
}, level)
})
//parse folders
result = result.map(el => parseFolder(el))
function parseFolder(el) {
if (el.children.length !== 0) {
let folder = createFolder(el.name, el.children)
folder.children = folder.children.map(el => parseFolder(el))
return folder
}
return el
}
return result
}
function createFile(data, name, children) {
return {
name: name,
progress: Math.round(data.progress * 100),
size: formatBytes(data.size),
icon: getIconForFileType(name.split('.').pop()),
children: children
}
}
function createFolder(name, children) {
return {
name: name,
type: 'directory',
children: children
}
}

View file

@ -4,28 +4,39 @@ import '@/registerServiceWorker'
import router from '@/router'
import store from '@/store'
import '@babel/polyfill'
// eslint-disable-next-line no-unused-vars
import filters from '@/filters'
import VueObserveVisibility from 'vue-observe-visibility'
Vue.use(VueObserveVisibility)
import Toast from 'vue-toastification'
import 'vue-toastification/dist/index.css'
import vuetify from './plugins/vuetify'
Vue.use(Toast)
Vue.use(Toast, {
maxToasts: 5,
timeout: 2000
})
import PerfectScrollbar from 'vue2-perfect-scrollbar'
import 'vue2-perfect-scrollbar/dist/vue2-perfect-scrollbar.css'
Vue.use(PerfectScrollbar)
import AsyncComputed from 'vue-async-computed'
Vue.use(AsyncComputed)
Vue.config.productionTip = false
// register modals
const files = require.context('@/components/Modals', true, /\.vue$/i)
files.keys().map(key =>
Vue.component(
key
.split('/')
.pop()
.split('.')[0],
files(key).default
files
.keys()
.map(key =>
Vue.component(key.split('/').pop().split('.')[0], files(key).default)
)
)
new Vue({
router,

View file

@ -1,14 +1,14 @@
import { mapGetters } from 'vuex'
export default {
computed: {
...mapGetters(['getModalState']),
dialog: {
get() {
return this.getModalState(this.$options.name)
},
set() {
this.$store.commit('TOGGLE_MODAL', this.$options.name)
}
}
}
}
import { mapGetters } from 'vuex'
export default {
computed: {
...mapGetters(['getModalState']),
dialog: {
get() {
return this.getModalState(this.$options.name)
},
set() {
this.$store.commit('TOGGLE_MODAL', this.$options.name)
}
}
}
}

View file

@ -1,20 +1,25 @@
export default class Stat {
constructor(data) {
if (data != undefined && data != null) {
this.status = data.connection_status
this.downloaded = this.formatBytes(data.dl_info_data, 1)
this.uploaded = this.formatBytes(data.up_info_data, 1)
this.dlspeed = this.formatBytes(data.dl_info_speed, 1)
this.upspeed = this.formatBytes(data.up_info_speed, 1)
}
}
formatBytes(a, b) {
if (a == 0) return '0 Bytes'
const c = 1024
const d = b || 2
const e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const f = Math.floor(Math.log(a) / Math.log(c))
return `${parseFloat((a / Math.pow(c, f)).toFixed(d))} ${e[f]}`
}
}
export default class Status {
constructor(data, tags) {
if (data != undefined && data != null) {
this.status = data.connection_status
this.downloaded = this.formatBytes(data.dl_info_data, 1)
this.uploaded = this.formatBytes(data.up_info_data, 1)
this.dlspeed = this.formatBytes(data.dl_info_speed, 1)
this.upspeed = this.formatBytes(data.up_info_speed, 1)
this.freeDiskSpace = this.formatBytes(data.free_space_on_disk)
this.altSpeed = data.use_alt_speed_limits
this.dlspeedRaw = Math.round(data.dl_info_speed / 1000)
this.upspeedRaw = Math.round(data.up_info_speed / 1000)
this.tags = tags
}
}
formatBytes(a, b) {
if (a == 0) return '0 Bytes'
const c = 1024
const d = b || 2
const e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const f = Math.floor(Math.log(a) / Math.log(c))
return `${parseFloat((a / Math.pow(c, f)).toFixed(d))} ${e[f]}`
}
}

View file

@ -1,6 +1,5 @@
export default class Torrent {
constructor(data) {
this.id = data.id
this.name = data.name
this.size = this.formatBytes(data.size)
this.birth = new Date(data.added_on * 1000).toLocaleString()
@ -20,6 +19,10 @@ export default class Torrent {
// available seeds
this.available_seeds = data.num_complete
this.available_peers = data.num_incomplete
this.savePath = data.save_path
this.progress = Math.round((data.downloaded / data.size) * 100)
this.ratio = Math.round(data.ratio * 100)
this.tags = data.tags.length > 0 ? data.tags.split(',') : null
}
formatState(state) {

View file

@ -1,51 +1,50 @@
import Vue from "vue";
import Vuetify from "vuetify";
import "vuetify/dist/vuetify.min.css";
import Vue from 'vue'
import Vuetify from 'vuetify'
import 'vuetify/dist/vuetify.min.css'
import colors from "vuetify/lib/util/colors";
import colors from 'vuetify/lib/util/colors'
Vue.use(Vuetify);
Vue.use(Vuetify)
export default new Vuetify({
icons: {
iconfont: "fa",
},
theme: {
options: {
customProperties: true,
},
dark: false,
themes: {
light: {
primary: "#35495e",
secondary: "#3e556d",
secondary_lighter: "#56718c",
blue_accent: "#3cd1c2",
info: "#ffaa2c",
error: "#f83e70",
green_accent: "#3cd1c2",
download: "#64CEAA",
upload: "#00b3fa",
torrent: '#fff',
torrent_selected: colors.grey.lighten2,
background: colors.grey.lighten4,
search: colors.grey.darken1
},
dark: {
primary: "#35495e",
secondary: "#3e556d",
secondary_lighter: "#56718c",
blue_accent: "#3cd1c2",
info: "#ffaa2c",
error: "#f83e70",
green_accent: "#3cd1c2",
download: "#64CEAA",
upload: "#00b3fa",
torrent: colors.grey.darken3,
torrent_selected: colors.grey,
background: colors.grey.darken4,
search: colors.grey.darken3
},
},
},
});
theme: {
options: {
customProperties: true
},
dark: false,
themes: {
light: {
primary: '#35495e',
secondary: '#3e556d',
secondary_lighter: '#56718c',
blue_accent: '#3cd1c2',
info: '#ffaa2c',
error: '#f83e70',
green_accent: '#3cd1c2',
download: '#64CEAA',
upload: '#00b3fa',
torrent: '#fff',
torrent_selected: colors.grey.lighten2,
background: colors.grey.lighten4,
search: colors.grey.darken1,
torrentmodaltext: colors.grey.darken4
},
dark: {
primary: '#35495e',
secondary: '#3e556d',
secondary_lighter: '#56718c',
blue_accent: '#3cd1c2',
info: '#ffaa2c',
error: '#f83e70',
green_accent: '#3cd1c2',
download: '#64CEAA',
upload: '#00b3fa',
torrent: colors.grey.darken3,
torrent_selected: colors.grey,
background: colors.grey.darken4,
search: colors.grey.darken3,
torrentmodaltext: colors.grey.lighten4
}
}
}
})

View file

@ -1,10 +1,12 @@
import Vue from 'vue'
import Router from 'vue-router'
import Dashboard from './views/Dashboard.vue'
import Dashboard from '@/views/Dashboard.vue'
import Login from '@/views/Login.vue'
import { isAuthenticated } from '@/services/auth.js'
Vue.use(Router)
export default new Router({
const router = new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
@ -12,6 +14,39 @@ export default new Router({
path: '/',
name: 'dashboard',
component: Dashboard
},
{
path: '/login',
name: 'login',
component: Login,
meta: {
public: true, // Allow access to even if not logged in
onlyWhenLoggedOut: true
}
}
]
})
router.beforeEach(async (to, from, next) => {
const isPublic = to.matched.some(record => record.meta.public)
const onlyWhenLoggedOut = to.matched.some(
record => record.meta.onlyWhenLoggedOut
)
const authenticated = await isAuthenticated()
if (!isPublic && !authenticated) {
return next({
path: '/login',
query: { redirect: to.fullPath } // Store the full path to redirect the user to after login
})
}
// Do not allow user to visit login page or register page if they are logged in
if (authenticated && onlyWhenLoggedOut) {
return next('/')
}
next()
})
export default router

6
src/services/auth.js Normal file
View file

@ -0,0 +1,6 @@
import qbit from '@/services/qbit'
export async function isAuthenticated() {
const res = await qbit.login()
return res === 'Ok.'
}

View file

@ -28,6 +28,10 @@ class Qbit {
return data
}
async logout() {
this.axios.post('/auth/logout')
}
getGlobalTransferInfo() {
return this.axios.get('/transfer/info')
}
@ -49,7 +53,7 @@ class Qbit {
let data
if (torrents) {
const formData = new FormData()
if(params){
if (params) {
for (const [key, value] of Object.entries(params)) {
formData.append(key, value)
}
@ -108,6 +112,24 @@ class Qbit {
return this.axios.post('/transfer/toggleSpeedLimitsMode')
}
getTorrents(payload) {
let params = {
sort: payload.sort,
reverse: payload.reverse,
hashes: payload.hashes ? payload.hashes.join('|') : null,
filter: payload.filter ? payload.filter : null
}
//clean
Object.keys(params).forEach(
key => params[key] == null && delete params[key]
)
const data = new URLSearchParams(params)
return this.axios.get(`/torrents/info?${data.toString()}`)
}
deleteTorrents(hashes, deleteFiles) {
return this.actionTorrents('delete', hashes, { deleteFiles })
}
@ -132,7 +154,7 @@ class Qbit {
return this.actionTorrents('setCategory', hashes, { category })
}
getTorrentTracker(hash) {
getTorrentTrackers(hash) {
const params = {
hash
}
@ -191,75 +213,44 @@ class Qbit {
})
}
getRssItems() {
const params = {
withData: true
}
return this.axios.get('/rss/items', {
params
})
getAvailableTags() {
return this.axios.get('/torrents/tags')
}
addRssFeed(url, path = '') {
removeTorrentTag(hash, tag) {
const params = {
url,
path
hashes: hash,
tags: tag
}
const data = new URLSearchParams(params)
return this.axios.post('/rss/addFeed', data)
return this.axios.post('/torrents/removeTags', data)
}
removeRssFeed(path) {
addTorrentTag(hash, tag) {
const params = {
path
hashes: hash,
tags: tag
}
const data = new URLSearchParams(params)
return this.axios.post('/rss/removeItem', data)
return this.axios.post('/torrents/addTags ', data)
}
refreshRssFeed(path) {
createTag(tag) {
const params = {
itemPath: path
tags: tag
}
const data = new URLSearchParams(params)
return this.axios.post('/rss/refreshItem', data)
return this.axios.post('/torrents/createTags ', data)
}
moveRssFeed(path, newPath) {
deleteTag(tag) {
const params = {
itemPath: path,
destPath: newPath
tags: tag
}
const data = new URLSearchParams(params)
return this.axios.post('/rss/moveItem', data)
}
getRssRules() {
return this.axios.get('/rss/rules')
}
setRssRule(name, def) {
const params = {
ruleName: name,
ruleDef: JSON.stringify(def)
}
const data = new URLSearchParams(params)
return this.axios.post('/rss/setRule', data)
}
removeRssRule(name) {
const params = {
ruleName: name
}
const data = new URLSearchParams(params)
return this.axios.post('/rss/removeRule', data)
return this.axios.post('/torrents/deleteTags ', data)
}
actionTorrents(action, hashes, extra) {

23
src/store/actions.js Normal file
View file

@ -0,0 +1,23 @@
import Vue from 'vue'
import qbit from '../services/qbit'
export default {
INIT_INTERVALS: async context => {
context.state.intervals[0] = setInterval(() => {
context.commit('updateMainData')
}, 2000)
},
LOGIN: async (context, payload) => {
const res = await qbit.login(payload)
console.log(res)
if (res === 'Ok.') {
Vue.$toast.success('Successfully logged in!')
context.commit('LOGIN', true)
context.commit('updateMainData')
context.commit('SET_SETTINGS')
return true
}
Vue.$toast.error('Log in failed 😕')
return false
}
}

11
src/store/getters.js Normal file
View file

@ -0,0 +1,11 @@
export default {
containsTorrent: state => hash => state.selected_torrents.includes(hash),
getTheme: state => () => state.webuiSettings.darkTheme,
getModalState: state => name => state.modals[name.toLowerCase()],
getSettings: state => () => state.settings,
getStatus: state => () => state.status,
getTorrent: state => hash =>
state.torrents.filter(el => el.hash === hash)[0],
getWebuiSettings: state => () => state.webuiSettings,
getAvailableTags: state => () => state.status.tags
}

View file

@ -2,10 +2,6 @@ import Vue from 'vue'
import Vuex from 'vuex'
import VuexPersist from 'vuex-persist'
import Torrent from '../models/torrent'
import Stat from '../models/sessionStat'
import qbit from '../services/qbit'
const vuexPersist = new VuexPersist({
key: 'vuetorrent',
storage: window.localStorage
@ -13,130 +9,49 @@ const vuexPersist = new VuexPersist({
Vue.use(Vuex)
import getters from './getters'
import mutations from './mutations'
import actions from './actions'
export default new Vuex.Store({
plugins: [vuexPersist.plugin],
state: {
darkTheme: false,
intervals: [],
stats: null,
status: null,
upload_data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
download_data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
torrents: [],
selected_torrents: [],
authenticated: false,
loading: false,
sort_options: { sort: 'name', reverse: false },
sort_options: {
sort: 'name',
reverse: false,
hashes: [],
filter: null
},
rid: 0,
mainData: undefined,
preferences: null,
pasteUrl: null,
modals: {
addmodal: false,
deletemodal: false,
settingsmodal: false
}
settingsmodal: false,
torrentdetailmodal: false
},
settings: {},
webuiSettings: {
darkTheme: false,
showFreeSpace: true
},
selectedDetailTorrent: null
},
getters: {
containsTorrent: state => hash =>
state.selected_torrents.includes(hash),
getTheme: state => () => state.darkTheme,
getModalState: state => name => state.modals[name.toLowerCase()]
...getters
},
mutations: {
REMOVE_INTERVALS: state => {
state.intervals.forEach(el => clearInterval(el))
},
TOGGLE_MODAL(state, modal) {
state.modals[modal.toLowerCase()] = !state.modals[
modal.toLowerCase()
]
},
SET_SELECTED: (state, payload) => {
if(payload.type === 'add')
state.selected_torrents.push(payload.hash)
if(payload.type === 'remove')
state.selected_torrents.splice(
state.selected_torrents.indexOf(payload.hash),
1
)
},
RESET_SELECTED: state => {
state.selected_torrents = []
},
TOGGLE_THEME(state) {
state.darkTheme = !state.darkTheme
},
REMOVE_TORRENTS: async state => {
if (state.selected_torrents.length !== 0) {
qbit.remove_torrents(state.selected_torrents)
}
},
LOGIN: async (state, payload) => {
const res = await qbit.login(payload)
console.log(res)
if (res === 'Ok.') {
Vue.$toast.success('Successfully logged in!')
state.authenticated = true
}
state.loading = false
},
updateMainData: async state => {
const rid = state.rid ? state.rid : undefined
const { data } = await qbit.getMainData(rid)
// torrents
state.torrents = []
for (const [key, value] of Object.entries(data.torrents)) {
state.torrents.push(new Torrent({ hash: key, ...value }))
}
// stats
state.stats = new Stat(data.server_state)
// graph
state.download_data.splice(0, 1)
if (state.stats.dlspeed.indexOf('KB' > -1)) {
state.download_data.push(
state.stats.dlspeed.substring(
0,
state.stats.dlspeed.indexOf(' ')
) / 1000
)
} else {
state.download_data.push(
state.stats.dlspeed(0, state.stats.dlspeed.indexOf(' '))
)
}
state.upload_data.splice(0, 1)
if (state.stats.upspeed.indexOf('KB' > -1)) {
state.upload_data.push(
state.stats.upspeed.substring(
0,
state.stats.upspeed.indexOf(' ')
) / 1000
)
} else {
state.upload_data.push(
state.stats.upspeed.substring(
0,
state.stats.upspeed.indexOf(' ')
)
)
}
}
...mutations
},
actions: {
INIT_INTERVALS: async context => {
context.state.intervals[0] = setInterval(() => {
context.commit('updateMainData')
}, 2000)
},
LOGIN: async (context, payload) => {
context.commit('LOGIN', payload)
context.commit('updateMainData')
}
...actions
}
})

66
src/store/mutations.js Normal file
View file

@ -0,0 +1,66 @@
import Torrent from '../models/torrent'
import Status from '../models/Status'
import qbit from '../services/qbit'
export default {
REMOVE_INTERVALS: state => {
state.intervals.forEach(el => clearInterval(el))
},
TOGGLE_MODAL(state, modal) {
state.modals[modal.toLowerCase()] = !state.modals[modal.toLowerCase()]
},
SET_SELECTED: (state, payload) => {
if (payload.type === 'add') state.selected_torrents.push(payload.hash)
if (payload.type === 'remove')
state.selected_torrents.splice(
state.selected_torrents.indexOf(payload.hash),
1
)
},
RESET_SELECTED: state => {
state.selected_torrents = []
},
TOGGLE_THEME(state) {
state.webuiSettings.darkTheme = !state.webuiSettings.darkTheme
},
LOGOUT: state => {
qbit.logout()
state.authenticated = false
},
LOGIN: async (state, payload) => {
state.authenticated = payload
},
updateMainData: async state => {
const rid = state.rid ? state.rid : undefined
const res = await qbit.getMainData(rid)
// status
state.status = new Status(res.data.server_state, res.data.tags)
// graph
state.download_data.splice(0, 1)
state.download_data.push(state.status.dlspeedRaw)
state.upload_data.splice(0, 1)
state.upload_data.push(state.status.upspeedRaw)
const { data } = await qbit.getTorrents(state.sort_options)
// torrents
state.torrents = []
for (const [key, value] of Object.entries(data)) {
state.torrents.push(new Torrent({ hash: key, ...value }))
}
},
SET_SETTINGS: async state => {
const { data } = await qbit.getAppPreferences()
state.settings.savePath = data.save_path
},
SET_SELECTED_TORRENT_DETAIL: (state, hash) => {
state.selectedDetailTorrent = hash
},
UPDATE_SORT_OPTIONS: (state, payload) => {
state.sort_options.sort = payload.name
state.sort_options.reverse = payload.reverse
state.sort_options.hashes = payload.hashes ? payload.hashes : null
state.sort_options.filter = payload.filter ? payload.filter : null
}
}

View file

@ -1,7 +1,13 @@
<template>
<div style="height: 89vh" color="background" @click.self="resetSelected">
<h1 style="font-size: 1.1em !important" class="subtitle-1 grey--text">Dashboard</h1>
<v-container color="background" class="my-4" @click.self="resetSelected">
<div class="pl-5 pr-5" color="background" @click.self="resetSelected">
<h1 style="font-size: 1.1em !important;" class="subtitle-1 grey--text">
Dashboard
</h1>
<v-container
color="background"
class="my-4 pt-5 pa-0"
@click.self="resetSelected"
>
<!-- justify-center here in layout to center!! -->
<v-flex xs12 sm6 md3 @click.self="resetSelected">
<v-text-field
@ -10,7 +16,7 @@
height="50"
clearable
solo
hint="eg `size desc` + enter"
hint="eg `s size desc` + enter"
color="search"
v-model="sort_input"
@keyup.enter.native="sortBy"
@ -20,26 +26,130 @@
<p class="grey--text">No active Torrents!</p>
</div>
<div v-else>
<div
v-for="torrent in torrents"
:key="torrent.hash"
>
<Torrent :torrent="torrent"/>
<div v-for="torrent in torrents" :key="torrent.hash">
<Torrent :torrent="torrent" />
</div>
</div>
</v-container>
<TorrentDetailModal />
</div>
</template>
<script>
import { mapState, mapMutations} from 'vuex'
import { mapState, mapMutations } from 'vuex'
import Torrent from '@/components/Torrent'
import TorrentDetailModal from '@/components/TorrentDetailModal/TorrentDetailModal'
function getPropName(prop) {
switch (prop) {
case 'title':
case 'name':
case 'Name':
case 'Title':
return 'name'
case 'size':
case 'Size':
return 'size'
case 'dlspeed':
case 'Dlspeed':
case 'Download':
case 'download':
case 'downloadspeed':
return 'dlspeed'
case 'upspeed':
case 'upload':
case 'Upload':
case 'Upspeed':
case 'uploadspeed':
return 'upspeed'
case 'leechs':
case 'leechers':
case 'leech':
case 'peers':
case 'Leechs':
case 'Leechers':
case 'Leech':
case 'Peers':
return 'num_leechs'
case 'seeds':
case 'seeders':
case 'Seeds':
case 'Seeders':
return 'num_seeds'
case 'remaining':
case 'time':
case 'Time':
case 'ETA':
case 'eta':
return 'eta'
case 'done':
case 'downloaded':
case 'dloaded':
case 'Done':
case 'Downloaded':
case 'Dloaded':
return 'progress'
case 'state':
case 'status':
case 'State':
case 'Status':
return 'state'
default:
return 'name'
}
}
function sortOrFilter(word) {
switch (word) {
case 'sort':
case 's':
case 'srt':
return 'sort'
case 'f':
case 'filter':
case 'filtr':
case 'fltr':
case 'filt':
return 'filter'
default:
return 'sort'
}
}
function filterOption(word) {
switch (word) {
case 'Done':
case 'done':
case 'completed':
case 'complete':
return 'completed'
case 'Busy':
case 'busy':
case 'downl':
case 'download':
case 'downloading':
case 'act':
case 'active':
case 'resumed':
return 'active'
case 'fail':
case 'failed':
case 'faild':
case 'stalled':
case 'stalld':
case 'stall':
return 'stalled'
case 'pause':
case 'paused':
return 'paused'
default:
return null
}
}
export default {
name:'Dashboard',
components: {Torrent},
name: 'Dashboard',
components: { Torrent, TorrentDetailModal },
data() {
return {
sort_input: ''
@ -51,92 +161,81 @@ export default {
methods: {
...mapMutations(['SORT_TORRENTS']),
sortBy() {
let name
let reverse
// search if order was presented
const index = this.sort_input.indexOf(' ')
if (index > -1) {
name = this.sort_input.substring(0, index)
const temp = this.sort_input.substring(index)
if (temp.indexOf('asc') > -1) {
reverse = false
} else if (temp.indexOf('desc') > -1) {
reverse = true
}
} else {
// no order so we assume input is propname
name = this.sort_input
reverse = false
let parts = this.sort_input.split(' ')
if (parts.length === 0) {
let name = 'name'
let reverse = false
return this.$store.commit('UPDATE_SORT_OPTIONS', {
name,
reverse
})
}
// prop names
switch (name) {
case 'title':
case 'name':
case 'Name':
case 'Title':
name = 'name'
break
case 'size':
case 'Size':
name = 'size'
break
case 'dlspeed':
case 'Dlspeed':
case 'Download':
case 'download':
case 'downloadspeed':
name = 'dlspeed'
break
case 'upspeed':
case 'upload':
case 'Upload':
case 'Upspeed':
case 'uploadspeed':
name = 'upspeed'
break
case 'leechs':
case 'leechers':
case 'leech':
case 'peers':
case 'Leechs':
case 'Leechers':
case 'Leech':
case 'Peers':
name = 'num_leechs'
break
case 'seeds':
case 'seeders':
case 'Seeds':
case 'Seeders':
name = 'num_seeds'
break
case 'remaining':
case 'time':
case 'Time':
case 'ETA':
case 'eta':
name = 'eta'
break
case 'done':
case 'downloaded':
case 'dloaded':
case 'Done':
case 'Downloaded':
case 'Dloaded':
name = 'downloaded'
break
case 'state':
case 'status':
case 'State':
case 'Status':
name = 'state'
break
default:
name = 'name'
break
//basic sort
if (parts.length === 1) {
let name = getPropName(parts[0])
let reverse = false
return this.$store.commit('UPDATE_SORT_OPTIONS', {
name,
reverse
})
}
this.$store.state.sort_options = { name, reverse }
// could be sort OR filter
if (parts.length === 2) {
let type = sortOrFilter(parts[0])
if (type === 'sort') {
let name = getPropName(parts[1])
let reverse = false
return this.$store.commit('UPDATE_SORT_OPTIONS', {
name,
reverse
})
}
if (type === 'filter') {
let ftype = filterOption(parts[1])
//filter state
if (ftype) {
let name = 'name'
let reverse = false
return this.$store.commit('UPDATE_SORT_OPTIONS', {
name,
reverse,
filter: ftype
})
}
//filter name
let filtered = this.torrents.filter(t =>
t.name.toLowerCase().includes(parts[1].toLowerCase())
)
let name = 'name'
let reverse = false
let hashes = filtered.map(t => t.hash)
return this.$store.commit('UPDATE_SORT_OPTIONS', {
name,
reverse,
hashes
})
}
}
//sort with asc/desc
if (parts.length === 3) {
let type = sortOrFilter(parts[0])
if (type === 'sort') {
let name = getPropName(parts[1])
let reverse = parts[2] === 'desc'
return this.$store.commit('UPDATE_SORT_OPTIONS', {
name,
reverse
})
}
}
},
resetSelected() {
this.$store.commit('RESET_SELECTED')
@ -150,5 +249,3 @@ export default {
}
}
</script>

79
src/views/Login.vue Normal file
View file

@ -0,0 +1,79 @@
<template>
<v-layout row wrap align-center class="justify-center">
<div style="margin: 100px auto;">
<v-container class="grey lighten-4 pa-0">
<v-card max-width="400" flat>
<v-container :class="`pa-3 project done`">
<v-card-title class="justify-center">
<h2>Login</h2>
</v-card-title>
<div class="mr-5 ml-5"></div>
<v-card-text>
<v-form class="px-3" ref="form">
<v-text-field
flat
solo
background-color="torrent"
label="username"
prepend-icon="person"
v-model="username"
:rules="inputRules"
@keyup.enter.native="Login"
autocomplete="current email"
name="username"
></v-text-field>
<v-text-field
flat
solo
background-color="torrent"
type="password"
label="password"
prepend-icon="lock"
v-model="password"
:rules="inputRules"
@keyup.enter.native="Login"
autocomplete="current password"
name="password"
></v-text-field>
<v-spacer></v-spacer>
<v-card-actions class="justify-center">
<v-btn
text
@click="Login"
class="blue_accent white--text mx-0 mt-3"
>Login</v-btn
>
</v-card-actions>
</v-form>
</v-card-text>
</v-container>
</v-card>
</v-container>
</div>
</v-layout>
</template>
<script>
export default {
name: 'Login',
data() {
return {
username: '',
password: '',
inputRules: [v => v.length >= 1 || 'At least 1 character']
}
},
methods: {
async Login() {
const authenticated = await this.$store.dispatch('LOGIN', {
username: this.username,
password: this.password
})
if (authenticated) {
this.$router.push('/')
}
}
}
}
</script>

View file

@ -1,7 +1,7 @@
module.exports = {
outputDir: 'vuetorrent/public',
publicPath: './',
transpileDependencies: ['vuetify'],
devServer: {
port: 8000,
proxy: {