mirror of
https://github.com/CzBiX/qb-web.git
synced 2026-02-03 02:24:38 +08:00
Upgrade to vuetify v2.0
This commit is contained in:
3
.browserslistrc
Normal file
3
.browserslistrc
Normal file
@@ -0,0 +1,3 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not ie <= 8
|
||||
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 100
|
||||
30
.eslintrc.js
Normal file
30
.eslintrc.js
Normal file
@@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
plugins: [
|
||||
'vuetify',
|
||||
],
|
||||
extends: [
|
||||
'plugin:vue/essential',
|
||||
'@vue/airbnb',
|
||||
'@vue/typescript',
|
||||
],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
|
||||
'no-restricted-syntax': 'off',
|
||||
'no-plusplus': 'off',
|
||||
'no-continue': 'off',
|
||||
'func-names': ['warn', 'as-needed'],
|
||||
|
||||
'vuetify/no-deprecated-classes': 'error',
|
||||
'vuetify/grid-unknown-attributes': 'error',
|
||||
'vuetify/no-legacy-grid': 'error',
|
||||
},
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
},
|
||||
};
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,4 +18,4 @@ yarn-error.log*
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw*
|
||||
*.sw?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/app'
|
||||
]
|
||||
}
|
||||
'@vue/app',
|
||||
],
|
||||
};
|
||||
|
||||
13569
package-lock.json
generated
Normal file
13569
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
package.json
49
package.json
@@ -9,39 +9,36 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^3.6.95",
|
||||
"axios": "^0.18.0",
|
||||
"axios": "^0.19.0",
|
||||
"core-js": "^2.6.5",
|
||||
"dayjs": "^1.8.12",
|
||||
"lodash": "^4.17.11",
|
||||
"dayjs": "^1.8.15",
|
||||
"lodash": "^4.17.15",
|
||||
"register-service-worker": "^1.6.2",
|
||||
"roboto-fontface": "*",
|
||||
"vue": "^2.6.10",
|
||||
"vuetify": "^1.5.12",
|
||||
"vuex": "^3.1.0"
|
||||
"vue-router": "^3.0.3",
|
||||
"vuetify": "^2.0.11",
|
||||
"vuex": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.123",
|
||||
"@vue/cli-plugin-babel": "^3.6.0",
|
||||
"@vue/cli-plugin-typescript": "^3.6.0",
|
||||
"@vue/cli-service": "^3.6.0",
|
||||
"fibers": "^3.1.1",
|
||||
"sass": "^1.19.0",
|
||||
"@types/lodash": "^4.14.137",
|
||||
"@vue/cli-plugin-babel": "^3.11.0",
|
||||
"@vue/cli-plugin-eslint": "^3.11.0",
|
||||
"@vue/cli-plugin-pwa": "^3.11.0",
|
||||
"@vue/cli-plugin-typescript": "^3.11.0",
|
||||
"@vue/cli-service": "^3.11.0",
|
||||
"@vue/eslint-config-airbnb": "^4.0.0",
|
||||
"@vue/eslint-config-typescript": "^4.0.0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-vue": "^5.0.0",
|
||||
"eslint-plugin-vuetify": "^1.0.0-beta.3",
|
||||
"node-sass": "^4.9.0",
|
||||
"sass": "^1.17.4",
|
||||
"sass-loader": "^7.1.0",
|
||||
"stylus": "^0.54.5",
|
||||
"stylus-loader": "^3.0.1",
|
||||
"typescript": "^3.4.4",
|
||||
"vue-cli-plugin-vuetify": "^0.5.0",
|
||||
"typescript": "^3.4.3",
|
||||
"vue-cli-plugin-vuetify": "^0.6.3",
|
||||
"vue-template-compiler": "^2.6.10",
|
||||
"vuetify-loader": "^1.2.2"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not ie <= 8"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
public/favicon.ico
Normal file → Executable file
BIN
public/favicon.ico
Normal file → Executable file
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 22 KiB |
307
src/Api.ts
307
src/Api.ts
@@ -1,153 +1,154 @@
|
||||
import 'axios';
|
||||
import Axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios';
|
||||
|
||||
class Api {
|
||||
private axios: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.axios = Axios.create({
|
||||
baseURL: '/api/v2',
|
||||
});
|
||||
|
||||
this.axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
|
||||
public getAppVersion() {
|
||||
return this.axios.get('/app/version');
|
||||
}
|
||||
|
||||
public getApiVersion() {
|
||||
return this.axios.get('/app/webapiVersion');
|
||||
}
|
||||
|
||||
public login(params: any) {
|
||||
const data = new URLSearchParams(params);
|
||||
return this.axios.post('/auth/login', data, {
|
||||
validateStatus(status) {
|
||||
return status === 200 || status === 403;
|
||||
},
|
||||
}).then(this.handleResponse);
|
||||
}
|
||||
|
||||
public getGlobalTransferInfo() {
|
||||
return this.axios.get('/transfer/info');
|
||||
}
|
||||
|
||||
public getAppPreferences() {
|
||||
return this.axios.get('/app/preferences');
|
||||
}
|
||||
|
||||
public getMainData(rid?: number) {
|
||||
const params = {
|
||||
rid,
|
||||
};
|
||||
return this.axios.get('/sync/maindata', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
public addTorrents(params: any, torrents?: any) {
|
||||
let data: any;
|
||||
if (torrents) {
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
for (const torrent of torrents) {
|
||||
formData.append('torrents', torrent);
|
||||
}
|
||||
|
||||
data = formData;
|
||||
} else {
|
||||
data = new URLSearchParams(params);
|
||||
}
|
||||
return this.axios.post('/torrents/add', data).then(this.handleResponse);
|
||||
}
|
||||
|
||||
public switchToOldUi() {
|
||||
const params = {
|
||||
alternative_webui_enabled: false,
|
||||
};
|
||||
|
||||
const data = new URLSearchParams({
|
||||
json: JSON.stringify(params),
|
||||
});
|
||||
|
||||
return this.axios.post('/app/setPreferences', data);
|
||||
}
|
||||
|
||||
public getLogs(lastId?: number) {
|
||||
const params = {
|
||||
last_known_id: lastId,
|
||||
};
|
||||
|
||||
return this.axios.get('/log/main', {
|
||||
params,
|
||||
}).then(this.handleResponse);
|
||||
}
|
||||
|
||||
public toggleSpeedLimitsMode() {
|
||||
return this.axios.post('/transfer/toggleSpeedLimitsMode');
|
||||
}
|
||||
|
||||
public deleteTorrents(hashes: string[], deleteFiles: boolean) {
|
||||
return this.actionTorrents('delete', hashes, {deleteFiles});
|
||||
}
|
||||
|
||||
public pauseTorrents(hashes: string[]) {
|
||||
return this.actionTorrents('pause', hashes);
|
||||
}
|
||||
|
||||
public resumeTorrents(hashes: string[]) {
|
||||
return this.actionTorrents('resume', hashes);
|
||||
}
|
||||
|
||||
public reannounceTorrents(hashes: string[]) {
|
||||
return this.actionTorrents('reannounce', hashes);
|
||||
}
|
||||
|
||||
public recheckTorrents(hashes: string[]) {
|
||||
return this.actionTorrents('recheck', hashes);
|
||||
}
|
||||
|
||||
public setTorrentsCategory(hashes: string[], category: string) {
|
||||
return this.actionTorrents('setCategory', hashes, {category});
|
||||
}
|
||||
|
||||
public getTorrentTracker(hash: string) {
|
||||
const params = {
|
||||
hash,
|
||||
};
|
||||
|
||||
return this.axios.get('/torrents/trackers', {
|
||||
params,
|
||||
}).then(this.handleResponse);
|
||||
}
|
||||
|
||||
public getTorrentPeers(hash: string, rid?: number) {
|
||||
const params = {
|
||||
hash,
|
||||
rid,
|
||||
};
|
||||
|
||||
return this.axios.get('/sync/torrentPeers', {
|
||||
params,
|
||||
}).then(this.handleResponse);
|
||||
}
|
||||
|
||||
private actionTorrents(action: string, hashes: string[], extra?: any) {
|
||||
const params: any = {
|
||||
hashes: hashes.join('|'),
|
||||
...extra,
|
||||
};
|
||||
const data = new URLSearchParams(params);
|
||||
return this.axios.post('/torrents/' + action, data).then(this.handleResponse);
|
||||
}
|
||||
|
||||
private handleResponse(resp: AxiosResponse) {
|
||||
return resp.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new Api();
|
||||
import Axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios';
|
||||
|
||||
|
||||
class Api {
|
||||
private axios: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.axios = Axios.create({
|
||||
baseURL: '/api/v2',
|
||||
});
|
||||
|
||||
this.axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
|
||||
public getAppVersion() {
|
||||
return this.axios.get('/app/version');
|
||||
}
|
||||
|
||||
public getApiVersion() {
|
||||
return this.axios.get('/app/webapiVersion');
|
||||
}
|
||||
|
||||
public login(params: any) {
|
||||
const data = new URLSearchParams(params);
|
||||
return this.axios.post('/auth/login', data, {
|
||||
validateStatus(status) {
|
||||
return status === 200 || status === 403;
|
||||
},
|
||||
}).then(Api.handleResponse);
|
||||
}
|
||||
|
||||
public getGlobalTransferInfo() {
|
||||
return this.axios.get('/transfer/info');
|
||||
}
|
||||
|
||||
public getAppPreferences() {
|
||||
return this.axios.get('/app/preferences');
|
||||
}
|
||||
|
||||
public getMainData(rid?: number) {
|
||||
const params = {
|
||||
rid,
|
||||
};
|
||||
return this.axios.get('/sync/maindata', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
public addTorrents(params: any, torrents?: any) {
|
||||
let data: any;
|
||||
if (torrents) {
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
// eslint-disable-next-line
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
for (const torrent of torrents) {
|
||||
formData.append('torrents', torrent);
|
||||
}
|
||||
|
||||
data = formData;
|
||||
} else {
|
||||
data = new URLSearchParams(params);
|
||||
}
|
||||
return this.axios.post('/torrents/add', data).then(Api.handleResponse);
|
||||
}
|
||||
|
||||
public switchToOldUi() {
|
||||
const params = {
|
||||
alternative_webui_enabled: false,
|
||||
};
|
||||
|
||||
const data = new URLSearchParams({
|
||||
json: JSON.stringify(params),
|
||||
});
|
||||
|
||||
return this.axios.post('/app/setPreferences', data);
|
||||
}
|
||||
|
||||
public getLogs(lastId?: number) {
|
||||
const params = {
|
||||
last_known_id: lastId,
|
||||
};
|
||||
|
||||
return this.axios.get('/log/main', {
|
||||
params,
|
||||
}).then(Api.handleResponse);
|
||||
}
|
||||
|
||||
public toggleSpeedLimitsMode() {
|
||||
return this.axios.post('/transfer/toggleSpeedLimitsMode');
|
||||
}
|
||||
|
||||
public deleteTorrents(hashes: string[], deleteFiles: boolean) {
|
||||
return this.actionTorrents('delete', hashes, { deleteFiles });
|
||||
}
|
||||
|
||||
public pauseTorrents(hashes: string[]) {
|
||||
return this.actionTorrents('pause', hashes);
|
||||
}
|
||||
|
||||
public resumeTorrents(hashes: string[]) {
|
||||
return this.actionTorrents('resume', hashes);
|
||||
}
|
||||
|
||||
public reannounceTorrents(hashes: string[]) {
|
||||
return this.actionTorrents('reannounce', hashes);
|
||||
}
|
||||
|
||||
public recheckTorrents(hashes: string[]) {
|
||||
return this.actionTorrents('recheck', hashes);
|
||||
}
|
||||
|
||||
public setTorrentsCategory(hashes: string[], category: string) {
|
||||
return this.actionTorrents('setCategory', hashes, { category });
|
||||
}
|
||||
|
||||
public getTorrentTracker(hash: string) {
|
||||
const params = {
|
||||
hash,
|
||||
};
|
||||
|
||||
return this.axios.get('/torrents/trackers', {
|
||||
params,
|
||||
}).then(Api.handleResponse);
|
||||
}
|
||||
|
||||
public getTorrentPeers(hash: string, rid?: number) {
|
||||
const params = {
|
||||
hash,
|
||||
rid,
|
||||
};
|
||||
|
||||
return this.axios.get('/sync/torrentPeers', {
|
||||
params,
|
||||
}).then(Api.handleResponse);
|
||||
}
|
||||
|
||||
private actionTorrents(action: string, hashes: string[], extra?: any) {
|
||||
const params: any = {
|
||||
hashes: hashes.join('|'),
|
||||
...extra,
|
||||
};
|
||||
const data = new URLSearchParams(params);
|
||||
return this.axios.post(`/torrents/${action}`, data).then(Api.handleResponse);
|
||||
}
|
||||
|
||||
private static handleResponse(resp: AxiosResponse) {
|
||||
return resp.data;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Api();
|
||||
|
||||
64
src/App.vue
64
src/App.vue
@@ -4,26 +4,29 @@
|
||||
app
|
||||
:clipped="$vuetify.breakpoint.lgAndUp"
|
||||
v-model="drawer"
|
||||
class="drawer"
|
||||
v-class:phone-layout="phoneLayout"
|
||||
:width="phoneLayout ? '300px' : '280px'"
|
||||
>
|
||||
<drawer v-model="drawerOptions" />
|
||||
<template v-if="phoneLayout">
|
||||
<v-spacer />
|
||||
<v-divider />
|
||||
<v-expansion-panel
|
||||
<v-expansion-panels
|
||||
class="drawer-footer"
|
||||
>
|
||||
<v-expansion-panel-content lazy @input="drawerFooterOpen">
|
||||
<template v-slot:header>
|
||||
<v-layout align-center>
|
||||
<v-expansion-panel lazy @input="drawerFooterOpen">
|
||||
<v-expansion-panel-header>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="footer-icon shrink">mdi-information-outline</v-icon>
|
||||
<span class="footer-title">Status info</span>
|
||||
</v-layout>
|
||||
</template>
|
||||
<app-footer phone-layout />
|
||||
</v-expansion-panel-content>
|
||||
</div>
|
||||
</v-expansion-panel-header>
|
||||
<v-expansion-panel-content>
|
||||
<app-footer phone-layout />
|
||||
</v-expansion-panel-content>
|
||||
</v-expansion-panel>
|
||||
<div ref="end" />
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
<main-toolbar v-model="drawer" />
|
||||
@@ -38,8 +41,8 @@
|
||||
|
||||
<v-footer
|
||||
app
|
||||
height="auto"
|
||||
class="elevation-4"
|
||||
padless
|
||||
v-if="$vuetify.breakpoint.smAndUp"
|
||||
>
|
||||
<app-footer />
|
||||
@@ -49,6 +52,10 @@
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import {
|
||||
mapActions, mapGetters, mapState, mapMutations,
|
||||
} from 'vuex';
|
||||
import Axios, { AxiosError } from 'axios';
|
||||
import AddForm from './components/AddForm.vue';
|
||||
import Drawer from './components/Drawer.vue';
|
||||
import LoginForm from './components/LoginForm.vue';
|
||||
@@ -56,12 +63,10 @@ import MainToolbar from './components/MainToolbar.vue';
|
||||
import Torrents from './components/Torrents.vue';
|
||||
import AppFooter from './components/Footer.vue';
|
||||
import LogsDialog from './components/dialogs/LogsDialog.vue';
|
||||
import { api } from './Api';
|
||||
import { mapActions, mapGetters, mapState, mapMutations } from 'vuex';
|
||||
import Axios, { AxiosError } from 'axios';
|
||||
import api from './Api';
|
||||
import { sleep } from './utils';
|
||||
|
||||
let appWrapEl = null;
|
||||
let appWrapEl: HTMLElement;
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'app',
|
||||
@@ -87,7 +92,7 @@ export default Vue.extend({
|
||||
},
|
||||
async created() {
|
||||
await this.getInitData();
|
||||
appWrapEl = this.$refs.app.$el.querySelector('.application--wrap')
|
||||
appWrapEl = this.$refs.app.$el.querySelector('.v-application--wrap');
|
||||
appWrapEl.addEventListener('paste', this.onPaste);
|
||||
},
|
||||
beforeDestroy() {
|
||||
@@ -140,8 +145,10 @@ export default Vue.extend({
|
||||
this.task = setTimeout(this.getMainData, this.config.updateInterval);
|
||||
},
|
||||
async drawerFooterOpen(v: boolean) {
|
||||
if (!v) return;
|
||||
await sleep(350);
|
||||
if (!v) {
|
||||
return;
|
||||
}
|
||||
await sleep(3000);
|
||||
|
||||
this.$refs.end.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
@@ -159,32 +166,33 @@ export default Vue.extend({
|
||||
if (!v) {
|
||||
await this.getInitData();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer {
|
||||
.phone-layout ::v-deep .v-navigation-drawer__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.drawer-footer {
|
||||
box-shadow: none;
|
||||
|
||||
::v-deep .v-expansion-panel__header {
|
||||
padding: 12px;
|
||||
}
|
||||
.drawer-footer .v-expansion-panel-header {
|
||||
padding: 12px 16px 12px 16px;
|
||||
|
||||
.footer-icon {
|
||||
font-size: 22px;
|
||||
margin-left: 10px;
|
||||
margin-right: 28px;
|
||||
margin-right: 34px;
|
||||
}
|
||||
|
||||
.footer-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-footer {
|
||||
min-height: 36px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
@import "~@mdi/font/scss/variables";
|
||||
@import "~@mdi/font/scss/functions";
|
||||
@import "~@mdi/font/scss/core";
|
||||
@import "~@mdi/font/scss/icons";
|
||||
@import "~@mdi/font/scss/extras";
|
||||
@import "~@mdi/font/scss/animated";
|
||||
|
||||
@font-face {
|
||||
font-family: "Material Design Icons";
|
||||
src: url("~@mdi/font/fonts/materialdesignicons-webfont.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@@ -1,272 +1,268 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-btn
|
||||
fab
|
||||
bottom
|
||||
color="primary"
|
||||
fixed
|
||||
right
|
||||
@click="dialog = !dialog"
|
||||
class="btn-add"
|
||||
:class="{'with-footer': $vuetify.breakpoint.smAndUp}"
|
||||
>
|
||||
<v-icon>mdi-link-plus</v-icon>
|
||||
</v-btn>
|
||||
<v-dialog v-model="dialog" persistent width="50em">
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-link-plus</v-icon>
|
||||
<span>Add Torrents</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form
|
||||
v-model="valid"
|
||||
ref="form"
|
||||
>
|
||||
<v-container pa-0 v-bind="{ [`grid-list-${$vuetify.breakpoint.name}`]: true }">
|
||||
<v-layout wrap>
|
||||
<v-flex
|
||||
xs12
|
||||
ref="fileZone"
|
||||
>
|
||||
<div
|
||||
v-show="files.length"
|
||||
class="files grey lighten-4 align-center justify-space-between subheading font-weight-medium pl-2"
|
||||
@click="selectFiles"
|
||||
>
|
||||
<input ref="file" type="file" multiple class="d-none" @change="onFilesChanged">
|
||||
<span v-if="files.length == 1">Selected file: {{ files[0].name }}</span>
|
||||
<span v-else>Selected {{ files.length }} files.</span>
|
||||
<v-btn icon @click.stop="files = []">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-show="!files.length"
|
||||
label="URLs"
|
||||
hint="One link per line"
|
||||
:placeholder="'Upload torrents by drop them here,\nor click attachment button at right to select.'"
|
||||
prepend-icon="mdi-link"
|
||||
append-outer-icon="mdi-attachment"
|
||||
:rules="[v => (!!files.length || !!v || 'URLs is required')]"
|
||||
:rows="$vuetify.breakpoint.xsOnly ? 1 : 3"
|
||||
required
|
||||
autofocus
|
||||
:value="params.urls"
|
||||
@input="setParams('urls', $event)"
|
||||
@click:append-outer="selectFiles"
|
||||
/>
|
||||
</v-flex>
|
||||
<v-flex>
|
||||
<v-combobox
|
||||
label="Category"
|
||||
prepend-icon="mdi-folder"
|
||||
clearable
|
||||
hide-no-data
|
||||
:items="categories"
|
||||
:value="params.category"
|
||||
@input="setParams('category', $event)"
|
||||
/>
|
||||
</v-flex>
|
||||
<v-flex>
|
||||
<v-checkbox
|
||||
v-model="autoStart"
|
||||
label="Start torrent"
|
||||
prepend-icon="mdi-play-pause"
|
||||
/>
|
||||
</v-flex>
|
||||
<v-flex>
|
||||
<v-checkbox
|
||||
prepend-icon="mdi-progress-check"
|
||||
label="Skip hash check"
|
||||
:value="params.skip_checking"
|
||||
@change="setParams('skip_checking', $event)"
|
||||
/>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</v-form>
|
||||
<v-alert
|
||||
type="warning"
|
||||
:value="error"
|
||||
v-text="error"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<!-- <v-btn flat>More</v-btn> -->
|
||||
<v-spacer />
|
||||
<v-btn flat @click="dialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
flat
|
||||
@click="submit"
|
||||
color="primary"
|
||||
:disabled="!valid"
|
||||
:loading="submitting"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import { api } from '../Api';
|
||||
|
||||
const defaultParams = {
|
||||
urls: null,
|
||||
category: null,
|
||||
paused: false,
|
||||
skip_checking: false,
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
url: String,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
valid: false,
|
||||
files: [],
|
||||
userParams: {},
|
||||
error: null,
|
||||
submitting: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
prefs: 'preferences',
|
||||
categories(state, getters) {
|
||||
return Object.keys(getters.torrentGroupByCategory).filter(_.identity);
|
||||
},
|
||||
}),
|
||||
params() {
|
||||
return Object.assign({}, defaultParams, this.userParams);
|
||||
},
|
||||
autoStart: {
|
||||
get(): boolean {
|
||||
return !this.params.paused;
|
||||
},
|
||||
set(value: boolean) {
|
||||
const paused = !value;
|
||||
const tmp = defaultParams.paused === paused ? null : paused;
|
||||
this.setParams('paused', tmp);
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
defaultParams.paused = this.prefs.start_paused_enabled;
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.fileZone.addEventListener('drop', this.onDrop, true);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$refs.fileZone.removeEventListener('drop', this.onDrop, true);
|
||||
},
|
||||
|
||||
methods: {
|
||||
setParams(key: string, value: any){
|
||||
if (_.isNil(value)) {
|
||||
Vue.delete(this.userParams, key);
|
||||
} else {
|
||||
Vue.set(this.userParams, key, value);
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.error = null;
|
||||
let files;
|
||||
if (this.files.length) {
|
||||
files = this.files;
|
||||
Vue.delete(this.userParams, 'urls');
|
||||
} else {
|
||||
files = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await api.addTorrents(this.userParams, files);
|
||||
|
||||
if (resp !== 'Ok.') {
|
||||
this.error = resp;
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message
|
||||
}
|
||||
|
||||
this.submitting = false;
|
||||
|
||||
if (this.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialog = false;
|
||||
|
||||
Vue.delete(this.userParams, 'urls');
|
||||
this.files = [];
|
||||
|
||||
this.$refs.form.resetValidation();
|
||||
},
|
||||
selectFiles() {
|
||||
this.$refs.file.click();
|
||||
},
|
||||
onFilesChanged() {
|
||||
this.files = this.$refs.file.files;
|
||||
},
|
||||
onDrop(e: DragEvent) {
|
||||
const transfer = e.dataTransfer!;
|
||||
const files = transfer.files;
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.files = files;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
url(v) {
|
||||
if (!v) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.dialog) {
|
||||
Vue.set(this.userParams, 'urls', v);
|
||||
this.dialog = true;
|
||||
}
|
||||
|
||||
this.$emit('input', null);
|
||||
},
|
||||
files(v) {
|
||||
this.$refs.form.validate();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.btn-add.with-footer {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.files {
|
||||
display: flex;
|
||||
height: 3em;
|
||||
|
||||
border: 1px dashed;
|
||||
border-color: grey !important;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div>
|
||||
<v-btn
|
||||
fab
|
||||
bottom
|
||||
color="primary"
|
||||
fixed
|
||||
right
|
||||
@click="dialog = !dialog"
|
||||
class="btn-add"
|
||||
:class="{'with-footer': $vuetify.breakpoint.smAndUp}"
|
||||
>
|
||||
<v-icon>mdi-link-plus</v-icon>
|
||||
</v-btn>
|
||||
<v-dialog v-model="dialog" eager persistent width="40em">
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-link-plus</v-icon>
|
||||
<span>Add Torrents</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="pb-0">
|
||||
<v-form
|
||||
v-model="valid"
|
||||
ref="form"
|
||||
>
|
||||
<v-container v-bind="{ [`grid-list-${$vuetify.breakpoint.name}`]: true }">
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
ref="fileZone"
|
||||
>
|
||||
<v-file-input
|
||||
v-show="files.length"
|
||||
v-model="files"
|
||||
ref="file"
|
||||
multiple
|
||||
chips
|
||||
outlined
|
||||
label="Files"
|
||||
/>
|
||||
<v-textarea
|
||||
v-show="!files.length"
|
||||
label="URLs"
|
||||
hint="One link per line"
|
||||
:placeholder="placeholder"
|
||||
prepend-icon="mdi-link"
|
||||
append-outer-icon="mdi-attachment"
|
||||
:rules="[v => (!!files.length || !!v || 'URLs is required')]"
|
||||
:rows="$vuetify.breakpoint.xsOnly ? 1 : 3"
|
||||
required
|
||||
autofocus
|
||||
:value="params.urls"
|
||||
@input="setParams('urls', $event)"
|
||||
@click:append-outer="selectFiles"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-combobox
|
||||
label="Category"
|
||||
prepend-icon="mdi-folder"
|
||||
clearable
|
||||
hide-no-data
|
||||
:items="categories"
|
||||
:value="params.category"
|
||||
@input="setParams('category', $event)"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-checkbox
|
||||
v-model="autoStart"
|
||||
label="Start torrent"
|
||||
prepend-icon="mdi-play-pause"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-checkbox
|
||||
prepend-icon="mdi-progress-check"
|
||||
label="Skip hash check"
|
||||
:value="params.skip_checking"
|
||||
@change="setParams('skip_checking', $event)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-form>
|
||||
<v-alert
|
||||
type="warning"
|
||||
:value="error"
|
||||
v-text="error"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<!-- <v-btn flat>More</v-btn> -->
|
||||
<v-spacer />
|
||||
<v-btn text @click="dialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
@click="submit"
|
||||
color="primary"
|
||||
:disabled="!valid"
|
||||
:loading="submitting"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import api from '../Api';
|
||||
|
||||
const defaultParams = {
|
||||
urls: null,
|
||||
category: null,
|
||||
paused: false,
|
||||
skip_checking: false,
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
url: String,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
placeholder: 'Upload torrents by drop them here,\nor click attachment button at right to select.',
|
||||
dialog: false,
|
||||
valid: false,
|
||||
files: [],
|
||||
userParams: {},
|
||||
error: null,
|
||||
submitting: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
prefs: 'preferences',
|
||||
categories(state, getters) {
|
||||
return Object.keys(getters.torrentGroupByCategory).filter(_.identity);
|
||||
},
|
||||
}),
|
||||
params() {
|
||||
return Object.assign({}, defaultParams, this.userParams);
|
||||
},
|
||||
autoStart: {
|
||||
get(): boolean {
|
||||
return !this.params.paused;
|
||||
},
|
||||
set(value: boolean) {
|
||||
const paused = !value;
|
||||
const tmp = defaultParams.paused === paused ? null : paused;
|
||||
this.setParams('paused', tmp);
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
defaultParams.paused = this.prefs.start_paused_enabled;
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.fileZone.addEventListener('drop', this.onDrop, true);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$refs.fileZone.removeEventListener('drop', this.onDrop, true);
|
||||
},
|
||||
|
||||
methods: {
|
||||
setParams(key: string, value: any) {
|
||||
if (_.isNil(value)) {
|
||||
Vue.delete(this.userParams, key);
|
||||
} else {
|
||||
Vue.set(this.userParams, key, value);
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.error = null;
|
||||
let files;
|
||||
if (this.files.length) {
|
||||
({ files } = this);
|
||||
Vue.delete(this.userParams, 'urls');
|
||||
} else {
|
||||
files = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await api.addTorrents(this.userParams, files);
|
||||
|
||||
if (resp !== 'Ok.') {
|
||||
this.error = resp;
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
}
|
||||
|
||||
this.submitting = false;
|
||||
|
||||
if (this.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialog = false;
|
||||
|
||||
Vue.delete(this.userParams, 'urls');
|
||||
this.files = [];
|
||||
|
||||
this.$refs.form.resetValidation();
|
||||
},
|
||||
selectFiles() {
|
||||
const input = this.$refs.file.$el.querySelector('input[type=file]');
|
||||
input.click();
|
||||
},
|
||||
onDrop(e: DragEvent) {
|
||||
const transfer = e.dataTransfer!;
|
||||
const { files } = transfer;
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.files = files;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
url(v) {
|
||||
if (!v) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.dialog) {
|
||||
Vue.set(this.userParams, 'urls', v);
|
||||
this.dialog = true;
|
||||
}
|
||||
|
||||
this.$emit('input', null);
|
||||
},
|
||||
files(v) {
|
||||
this.$refs.form.validate();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.btn-add.with-footer {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 12px 0 0;
|
||||
|
||||
.col {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,251 +1,260 @@
|
||||
<template>
|
||||
<v-list
|
||||
dense
|
||||
expand
|
||||
>
|
||||
<template v-for="item in items">
|
||||
<v-layout
|
||||
v-if="item.heading"
|
||||
:key="item.heading"
|
||||
row
|
||||
align-center
|
||||
>
|
||||
<v-flex xs6>
|
||||
<v-subheader v-if="item.heading">
|
||||
{{ item.heading }}
|
||||
</v-subheader>
|
||||
</v-flex>
|
||||
<v-flex xs6 class="text-xs-center">
|
||||
<a href="#!" class="body-2 black--text">EDIT</a>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
<v-list-group
|
||||
v-else-if="item.children"
|
||||
:key="item.text"
|
||||
v-model="item.model"
|
||||
:prepend-icon="item.model ? item.icon : item['icon-alt']"
|
||||
append-icon=""
|
||||
>
|
||||
<template v-slot:activator>
|
||||
<v-list-tile>
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>
|
||||
{{ item.text }}
|
||||
</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</template>
|
||||
<v-list-tile
|
||||
v-for="(child, i) in item.children"
|
||||
:key="i"
|
||||
@click="item.click ? item.click(child.value) : null"
|
||||
>
|
||||
<v-list-tile-action v-if="child.icon">
|
||||
<v-icon>{{ child.icon }}</v-icon>
|
||||
</v-list-tile-action>
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>
|
||||
{{ child.text }}
|
||||
</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</v-list-group>
|
||||
<template v-else-if="item.filterGroups">
|
||||
<filter-group
|
||||
v-for="(child, i) in item.filterGroups"
|
||||
:key="i"
|
||||
:group="child"
|
||||
/>
|
||||
</template>
|
||||
<v-list-tile v-else :key="item.text" @click="item.click ? item.click() : null">
|
||||
<v-list-tile-action>
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</v-list-tile-action>
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>
|
||||
{{ item.text }}
|
||||
</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</template>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
import FilterGroup from './drawer/FilterGroup.vue';
|
||||
import { api } from '../Api';
|
||||
import { mapState, mapMutations, mapGetters } from 'vuex';
|
||||
import { formatSize } from '../filters';
|
||||
import { SiteMap, StateType, AllStateTypes } from '../consts';
|
||||
|
||||
const stateList = [
|
||||
{
|
||||
title: 'Downloading',
|
||||
state: StateType.Downloading,
|
||||
icon: 'download',
|
||||
},
|
||||
{
|
||||
title: 'Seeding',
|
||||
state: StateType.Seeding,
|
||||
icon: 'upload',
|
||||
},
|
||||
{
|
||||
title: 'Completed',
|
||||
state: StateType.Completed,
|
||||
icon: 'check',
|
||||
},
|
||||
{
|
||||
title: 'Resumed',
|
||||
state: StateType.Resumed,
|
||||
icon: 'play',
|
||||
},
|
||||
{
|
||||
title: 'Paused',
|
||||
state: StateType.Paused,
|
||||
icon: 'pause',
|
||||
},
|
||||
{
|
||||
title: 'Active',
|
||||
state: StateType.Active,
|
||||
icon: 'filter',
|
||||
},
|
||||
{
|
||||
title: 'Inactive',
|
||||
state: StateType.Inactive,
|
||||
icon: 'filter-outline',
|
||||
},
|
||||
{
|
||||
title: 'Errored',
|
||||
state: StateType.Errored,
|
||||
icon: 'alert',
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterGroup,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: Object,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
basicItems: null,
|
||||
endItems: null,
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.basicItems = [
|
||||
{ icon: 'mdi-settings', text: 'Settings', click: () => alert('TODO') },
|
||||
];
|
||||
this.endItems = [
|
||||
{ icon: 'mdi-delta', text: 'Logs', click: () => this.updateOptions('showLogs', true) },
|
||||
{ icon: 'mdi-history', text: 'Switch to old UI', click: this.switchUi },
|
||||
];
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'isDataReady',
|
||||
'allTorrents',
|
||||
'allCategories',
|
||||
'torrentGroupByCategory',
|
||||
'torrentGroupBySite',
|
||||
'torrentGroupByState',
|
||||
]),
|
||||
items() {
|
||||
if (!this.isDataReady) {
|
||||
return _.concat(this.basicItems, this.endItems);
|
||||
}
|
||||
|
||||
const filterGroups = [];
|
||||
const totalSize = formatSize(_.sumBy(this.allTorrents, 'size'));
|
||||
|
||||
const states = stateList.map((item) => {
|
||||
let value = this.torrentGroupByState[item.state];
|
||||
if (_.isUndefined(value)) {
|
||||
value = [];
|
||||
}
|
||||
const size = formatSize(_.sumBy(value, 'size'));
|
||||
const title = item.title + ` (${value.length})`;
|
||||
const append = `[${size}]`;
|
||||
return { icon: 'mdi-' + item.icon, title, key: item.state, append};
|
||||
});
|
||||
filterGroups.push({
|
||||
'icon': 'mdi-menu-up',
|
||||
'icon-alt': 'mdi-menu-down',
|
||||
'title': 'State',
|
||||
'model': true,
|
||||
'select': 'state',
|
||||
'children': [
|
||||
{ icon: 'mdi-filter-remove', title: `All (${this.allTorrents.length})`, key: null, append: `[${totalSize}]` },
|
||||
...states,
|
||||
],
|
||||
});
|
||||
|
||||
const categories: any[] = [{
|
||||
key: '',
|
||||
name: 'Uncategorized',
|
||||
}].concat(this.allCategories).map(category => {
|
||||
let value = this.torrentGroupByCategory[category.key]
|
||||
if (_.isUndefined(value)) {
|
||||
value = [];
|
||||
}
|
||||
const size = formatSize(_.sumBy(value, 'size'));
|
||||
const title = category.name + ` (${value.length})`;
|
||||
const append = `[${size}]`;
|
||||
return { icon: 'mdi-folder-open', title, key: category.key, append};
|
||||
});
|
||||
filterGroups.push({
|
||||
'icon': 'mdi-menu-up',
|
||||
'icon-alt': 'mdi-menu-down',
|
||||
'title': 'Categories',
|
||||
'model': !this.$vuetify.breakpoint.xsOnly,
|
||||
'select': 'category',
|
||||
'children': categories,
|
||||
});
|
||||
|
||||
const sites: any[] = _.sortBy(Object.entries(this.torrentGroupBySite).map(([key, value]) => {
|
||||
const size = formatSize(_.sumBy(value, 'size'));
|
||||
const site = (SiteMap as any)[key];
|
||||
const title = (site ? site.name : (key ? key : 'Others')) + ` (${value.length})`;
|
||||
const icon = _.defaultTo(site ? site.icon : null, 'mdi-server');
|
||||
const append = `[${size}]`;
|
||||
return { icon, title, key, append };
|
||||
}), 'title');
|
||||
filterGroups.push({
|
||||
'icon': 'mdi-menu-up',
|
||||
'icon-alt': 'mdi-menu-down',
|
||||
'title': 'Sites',
|
||||
'model': false,
|
||||
'select': 'site',
|
||||
'children': sites,
|
||||
});
|
||||
|
||||
return _.concat(this.basicItems, [{filterGroups}], this.endItems);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async switchUi() {
|
||||
await api.switchToOldUi();
|
||||
|
||||
location.reload(true);
|
||||
},
|
||||
updateOptions(key: string, value: any) {
|
||||
this.$emit('input', Object.assign({}, this.value, {[key]: value}));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-list__tile__action {
|
||||
padding-left: 6px;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<v-list
|
||||
dense
|
||||
expand
|
||||
class="drawer"
|
||||
>
|
||||
<template v-for="item in items">
|
||||
<v-row
|
||||
v-if="item.heading"
|
||||
:key="item.heading"
|
||||
align="center"
|
||||
>
|
||||
<v-col cols="6">
|
||||
<v-subheader v-if="item.heading">
|
||||
{{ item.heading }}
|
||||
</v-subheader>
|
||||
</v-col>
|
||||
<v-col cols="6" class="text-center">
|
||||
<a href="#!" class="body-2 black--text">EDIT</a>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-list-group
|
||||
v-else-if="item.children"
|
||||
:key="item.text"
|
||||
v-model="item.model"
|
||||
:prepend-icon="item.model ? item.icon : item['icon-alt']"
|
||||
append-icon=""
|
||||
>
|
||||
<template v-slot:activator>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{ item.text }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
<v-list-item
|
||||
v-for="(child, i) in item.children"
|
||||
:key="i"
|
||||
@click="item.click ? item.click(child.value) : null"
|
||||
>
|
||||
<v-list-item-icon v-if="child.icon">
|
||||
<v-icon>{{ child.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{ child.text }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
<template v-else-if="item.filterGroups">
|
||||
<filter-group
|
||||
v-for="(child, i) in item.filterGroups"
|
||||
:key="i"
|
||||
:group="child"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item v-else :key="item.text" @click="item.click ? item.click() : null">
|
||||
<v-list-item-icon>
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{ item.text }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
import { mapState, mapMutations, mapGetters } from 'vuex';
|
||||
import FilterGroup from './drawer/FilterGroup.vue';
|
||||
import api from '../Api';
|
||||
import { formatSize } from '../filters';
|
||||
import { SiteMap, StateType, AllStateTypes } from '../consts';
|
||||
|
||||
const stateList = [
|
||||
{
|
||||
title: 'Downloading',
|
||||
state: StateType.Downloading,
|
||||
icon: 'download',
|
||||
},
|
||||
{
|
||||
title: 'Seeding',
|
||||
state: StateType.Seeding,
|
||||
icon: 'upload',
|
||||
},
|
||||
{
|
||||
title: 'Completed',
|
||||
state: StateType.Completed,
|
||||
icon: 'check',
|
||||
},
|
||||
{
|
||||
title: 'Resumed',
|
||||
state: StateType.Resumed,
|
||||
icon: 'play',
|
||||
},
|
||||
{
|
||||
title: 'Paused',
|
||||
state: StateType.Paused,
|
||||
icon: 'pause',
|
||||
},
|
||||
{
|
||||
title: 'Active',
|
||||
state: StateType.Active,
|
||||
icon: 'filter',
|
||||
},
|
||||
{
|
||||
title: 'Inactive',
|
||||
state: StateType.Inactive,
|
||||
icon: 'filter-outline',
|
||||
},
|
||||
{
|
||||
title: 'Errored',
|
||||
state: StateType.Errored,
|
||||
icon: 'alert',
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterGroup,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: Object,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
basicItems: null,
|
||||
endItems: null,
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.basicItems = [
|
||||
{ icon: 'mdi-settings', text: 'Settings', click: () => alert('TODO') },
|
||||
];
|
||||
this.endItems = [
|
||||
{ icon: 'mdi-delta', text: 'Logs', click: () => this.updateOptions('showLogs', true) },
|
||||
{ icon: 'mdi-history', text: 'Switch to old UI', click: this.switchUi },
|
||||
];
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'isDataReady',
|
||||
'allTorrents',
|
||||
'allCategories',
|
||||
'torrentGroupByCategory',
|
||||
'torrentGroupBySite',
|
||||
'torrentGroupByState',
|
||||
]),
|
||||
items(): Array<any> {
|
||||
if (!this.isDataReady) {
|
||||
return _.concat(this.basicItems, this.endItems);
|
||||
}
|
||||
|
||||
const filterGroups = [];
|
||||
const totalSize = formatSize(_.sumBy(this.allTorrents, 'size'));
|
||||
|
||||
const states = stateList.map((item) => {
|
||||
let value = this.torrentGroupByState[item.state];
|
||||
if (_.isUndefined(value)) {
|
||||
value = [];
|
||||
}
|
||||
const size = formatSize(_.sumBy(value, 'size'));
|
||||
const title = `${item.title} (${value.length})`;
|
||||
const append = `[${size}]`;
|
||||
return {
|
||||
icon: `mdi-${item.icon}`, title, key: item.state, append,
|
||||
};
|
||||
});
|
||||
filterGroups.push({
|
||||
icon: 'mdi-menu-up',
|
||||
'icon-alt': 'mdi-menu-down',
|
||||
title: 'State',
|
||||
model: true,
|
||||
select: 'state',
|
||||
children: [
|
||||
{
|
||||
icon: 'mdi-filter-remove', title: `All (${this.allTorrents.length})`, key: null, append: `[${totalSize}]`,
|
||||
},
|
||||
...states,
|
||||
],
|
||||
});
|
||||
|
||||
const categories: any[] = [{
|
||||
key: '',
|
||||
name: 'Uncategorized',
|
||||
}].concat(this.allCategories).map((category) => {
|
||||
let value = this.torrentGroupByCategory[category.key];
|
||||
if (_.isUndefined(value)) {
|
||||
value = [];
|
||||
}
|
||||
const size = formatSize(_.sumBy(value, 'size'));
|
||||
const title = `${category.name} (${value.length})`;
|
||||
const append = `[${size}]`;
|
||||
return {
|
||||
icon: 'mdi-folder-open', title, key: category.key, append,
|
||||
};
|
||||
});
|
||||
filterGroups.push({
|
||||
icon: 'mdi-menu-up',
|
||||
'icon-alt': 'mdi-menu-down',
|
||||
title: 'Categories',
|
||||
model: !this.$vuetify.breakpoint.xsOnly,
|
||||
select: 'category',
|
||||
children: categories,
|
||||
});
|
||||
|
||||
const sites: any[] = _.sortBy(Object.entries(this.torrentGroupBySite).map(([key, value]) => {
|
||||
const size = formatSize(_.sumBy(value, 'size'));
|
||||
const site = (SiteMap as any)[key];
|
||||
const title = `${site ? site.name : (key || 'Others')} (${value.length})`;
|
||||
const icon = _.defaultTo(site ? site.icon : null, 'mdi-server');
|
||||
const append = `[${size}]`;
|
||||
return {
|
||||
icon, title, key, append,
|
||||
};
|
||||
}), 'title');
|
||||
filterGroups.push({
|
||||
icon: 'mdi-menu-up',
|
||||
'icon-alt': 'mdi-menu-down',
|
||||
title: 'Sites',
|
||||
model: false,
|
||||
select: 'site',
|
||||
children: sites,
|
||||
});
|
||||
|
||||
return _.concat(this.basicItems, [{ filterGroups }], this.endItems);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async switchUi() {
|
||||
await api.switchToOldUi();
|
||||
|
||||
window.location.reload(true);
|
||||
},
|
||||
updateOptions(key: string, value: any) {
|
||||
this.$emit('input', Object.assign({}, this.value, { [key]: value }));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer ::v-deep {
|
||||
.v-list-item__icon {
|
||||
margin-left: 8px;
|
||||
margin-right: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,281 +1,290 @@
|
||||
<template>
|
||||
<v-layout
|
||||
v-bind="topLayoutBind"
|
||||
v-if="isDataReady">
|
||||
<v-flex shrink v-if="app">
|
||||
<v-layout
|
||||
:column="phoneLayout"
|
||||
:align-center="!phoneLayout"
|
||||
>
|
||||
<v-flex v-if="!phoneLayout">
|
||||
<v-tooltip top lazy>
|
||||
<template v-slot:activator="{ on }">
|
||||
<span v-on="on">
|
||||
qBittorrent {{ app.version }}
|
||||
</span>
|
||||
</template>
|
||||
<span>
|
||||
API version: {{ app.apiVersion }}
|
||||
</span>
|
||||
</v-tooltip>
|
||||
</v-flex>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<v-flex class="icon-label">
|
||||
<v-icon>mdi-sprout</v-icon>
|
||||
{{ allTorrents.length }} [{{ totalSize | formatSize }}]
|
||||
</v-flex>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<v-flex class="icon-label">
|
||||
<v-icon>mdi-nas</v-icon>
|
||||
{{ info.free_space_on_disk | formatSize }}
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-flex>
|
||||
<v-flex shrink v-if="info">
|
||||
<v-layout
|
||||
:column="phoneLayout"
|
||||
:align-center="!phoneLayout"
|
||||
>
|
||||
<v-flex v-if="!phoneLayout" class="icon-label">
|
||||
<v-icon>mdi-access-point-network</v-icon>
|
||||
{{ info.dht_nodes }} nodes
|
||||
</v-flex>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<v-flex class="icon-label">
|
||||
<v-tooltip top lazy>
|
||||
<template v-slot:activator="{ on }">
|
||||
<v-icon
|
||||
v-on="on"
|
||||
:color="info.connection_status | connectionIconColor"
|
||||
>mdi-{{ info.connection_status | connectionIcon }}</v-icon>
|
||||
<span v-if="phoneLayout">
|
||||
Network {{ info.connection_status }}
|
||||
</span>
|
||||
</template>
|
||||
<span>
|
||||
Network {{ info.connection_status }}
|
||||
</span>
|
||||
</v-tooltip>
|
||||
</v-flex>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<v-flex class="icon-label">
|
||||
<v-switch
|
||||
v-if="phoneLayout"
|
||||
hide-details
|
||||
:value="speedLimited"
|
||||
@change="toggleSpeedLimitsMode"
|
||||
label="Alternative speed limits"
|
||||
class="mt-0 pt-0 speed-switch"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon
|
||||
v-bind="speedModeBind"
|
||||
>mdi-speedometer</v-icon>
|
||||
</template>
|
||||
</v-switch>
|
||||
<v-tooltip top lazy v-else>
|
||||
<template v-slot:activator="{ on }">
|
||||
<v-icon
|
||||
v-on="on"
|
||||
v-bind="speedModeBind"
|
||||
@click="toggleSpeedLimitsMode"
|
||||
>mdi-speedometer</v-icon>
|
||||
</template>
|
||||
<span>
|
||||
Alternative speed limits {{ speedLimited ? 'enabled' : 'disabled' }}
|
||||
</span>
|
||||
</v-tooltip>
|
||||
</v-flex>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<v-flex class="icon-label">
|
||||
<v-icon
|
||||
:color=" info.dl_info_speed > 0 ? 'success' : null"
|
||||
>mdi-download</v-icon>
|
||||
<span>
|
||||
{{ info.dl_info_speed | formatSize }}/s
|
||||
<template v-if="info.dl_rate_limit">
|
||||
({{ info.dl_rate_limit | formatSize}}/s)
|
||||
</template>
|
||||
<template v-if="!phoneLayout">
|
||||
[{{ info.dl_info_data | formatSize }}/{{ info.alltime_dl | formatSize }}]
|
||||
</template>
|
||||
</span>
|
||||
</v-flex>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<v-flex class="icon-label">
|
||||
<v-icon
|
||||
:color=" info.up_info_speed > 0 ? 'warning' : null"
|
||||
>mdi-upload</v-icon>
|
||||
<span>
|
||||
{{ info.up_info_speed | formatSize }}/s
|
||||
<template v-if="info.up_rate_limit">
|
||||
({{ info.up_rate_limit | formatSize}}/s)
|
||||
</template>
|
||||
<template v-if="!phoneLayout">
|
||||
[{{ info.up_info_data | formatSize }}/{{ info.alltime_ul | formatSize }}]
|
||||
</template>
|
||||
</span>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { api } from '../Api';
|
||||
import { mapState, mapGetters } from 'vuex';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
phoneLayout: Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
app: null,
|
||||
speedLimited: false,
|
||||
};
|
||||
},
|
||||
|
||||
filters: {
|
||||
connectionIcon(status: string) {
|
||||
const statusMap: any = {
|
||||
connected: 'server-network',
|
||||
firewalled: 'server-network-off',
|
||||
disconnected: 'security-network',
|
||||
};
|
||||
return statusMap[status];
|
||||
},
|
||||
connectionIconColor(status: string) {
|
||||
const statusMap: any = {
|
||||
connected: 'success',
|
||||
firewalled: 'warning',
|
||||
disconnected: 'error',
|
||||
};
|
||||
return statusMap[status];
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState({
|
||||
info(state: any) {
|
||||
return this.isDataReady ? state.mainData.server_state : null;
|
||||
},
|
||||
}),
|
||||
...mapGetters([
|
||||
'isDataReady',
|
||||
'allTorrents'
|
||||
]),
|
||||
totalSize() {
|
||||
return _.sumBy(this.allTorrents, 'size');
|
||||
},
|
||||
speedModeBind() {
|
||||
if (this.speedLimited) {
|
||||
return {
|
||||
class: 'speed-limited',
|
||||
color: 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
class: null,
|
||||
color: 'success'
|
||||
}
|
||||
},
|
||||
topLayoutBind() {
|
||||
const v: boolean = this.phoneLayout;
|
||||
return {
|
||||
column: v,
|
||||
class: v ? 'in-drawer' : null,
|
||||
'mx-4': !v,
|
||||
'fill-height': !v,
|
||||
'align-center': !v,
|
||||
'justify-space-between': !v,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async getAppInfo() {
|
||||
let resp = await api.getAppVersion();
|
||||
const version = resp.data;
|
||||
|
||||
resp = await api.getApiVersion();
|
||||
const apiVersion = resp.data;
|
||||
|
||||
this.app = {
|
||||
version, apiVersion,
|
||||
};
|
||||
},
|
||||
async toggleSpeedLimitsMode() {
|
||||
this.speedLimited = !this.speedLimited;
|
||||
await api.toggleSpeedLimitsMode();
|
||||
},
|
||||
},
|
||||
|
||||
async created() {
|
||||
if (!this.isDataReady) {
|
||||
return;
|
||||
}
|
||||
this.speedLimited = this.info.use_alt_speed_limits;
|
||||
await this.getAppInfo();
|
||||
},
|
||||
|
||||
watch: {
|
||||
async isDataReady(v) {
|
||||
if (v && this.app === null) {
|
||||
await this.getAppInfo();
|
||||
}
|
||||
},
|
||||
'info.use_alt_speed_limits'(v) {
|
||||
this.speedLimited = v;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.icon-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.speed-switch {
|
||||
font-size: inherit;
|
||||
|
||||
::v-deep {
|
||||
.v-input__prepend-outer {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.v-input__control {
|
||||
margin-left: 4px;
|
||||
width: 100%;
|
||||
|
||||
.v-input__slot {
|
||||
justify-content: space-between;
|
||||
|
||||
.v-input--selection-controls__input {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.v-label {
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.speed-limited {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.in-drawer {
|
||||
padding: 0 16px 0 20px;
|
||||
|
||||
.no-icon {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div
|
||||
class="footer d-flex"
|
||||
:class="topLayoutClass"
|
||||
v-if="isDataReady">
|
||||
<div
|
||||
class="d-flex shrink footer-row"
|
||||
:class="phoneLayout ? 'flex-column' : 'align-center'"
|
||||
v-if="app"
|
||||
>
|
||||
<div v-if="!phoneLayout">
|
||||
<v-tooltip top>
|
||||
<template v-slot:activator="{ on }">
|
||||
<span v-on="on">
|
||||
qBittorrent {{ app.version }}
|
||||
</span>
|
||||
</template>
|
||||
<span>
|
||||
API version: {{ app.apiVersion }}
|
||||
</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<div class="icon-label">
|
||||
<v-icon>mdi-sprout</v-icon>
|
||||
{{ allTorrents.length }} [{{ totalSize | formatSize }}]
|
||||
</div>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<div class="icon-label">
|
||||
<v-icon>mdi-nas</v-icon>
|
||||
{{ info.free_space_on_disk | formatSize }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex shrink footer-row"
|
||||
:class="phoneLayout ? 'flex-column' : 'align-center'"
|
||||
v-if="info"
|
||||
>
|
||||
<div v-if="!phoneLayout" class="icon-label">
|
||||
<v-icon>mdi-access-point-network</v-icon>
|
||||
{{ info.dht_nodes }} nodes
|
||||
</div>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<div class="icon-label">
|
||||
<v-tooltip top>
|
||||
<template v-slot:activator="{ on }">
|
||||
<v-icon
|
||||
v-on="on"
|
||||
:color="info.connection_status | connectionIconColor"
|
||||
>mdi-{{ info.connection_status | connectionIcon }}</v-icon>
|
||||
<span v-if="phoneLayout">
|
||||
Network {{ info.connection_status }}
|
||||
</span>
|
||||
</template>
|
||||
<span>
|
||||
Network {{ info.connection_status }}
|
||||
</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<div class="icon-label">
|
||||
<v-switch
|
||||
v-if="phoneLayout"
|
||||
hide-details
|
||||
:value="speedLimited"
|
||||
@change="toggleSpeedLimitsMode"
|
||||
label="Alternative speed limits"
|
||||
class="mt-0 pt-0 speed-switch"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon
|
||||
v-bind="speedModeBind"
|
||||
>mdi-speedometer</v-icon>
|
||||
</template>
|
||||
</v-switch>
|
||||
<v-tooltip top v-else>
|
||||
<template v-slot:activator="{ on }">
|
||||
<v-icon
|
||||
v-on="on"
|
||||
v-bind="speedModeBind"
|
||||
@click="toggleSpeedLimitsMode"
|
||||
>mdi-speedometer</v-icon>
|
||||
</template>
|
||||
<span>
|
||||
Alternative speed limits {{ speedLimited ? 'enabled' : 'disabled' }}
|
||||
</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<div class="icon-label">
|
||||
<v-icon
|
||||
:color=" info.dl_info_speed > 0 ? 'success' : null"
|
||||
>mdi-download</v-icon>
|
||||
<span>
|
||||
{{ info.dl_info_speed | formatSize }}/s
|
||||
<template v-if="info.dl_rate_limit">
|
||||
({{ info.dl_rate_limit | formatSize}}/s)
|
||||
</template>
|
||||
<template v-if="!phoneLayout">
|
||||
[{{ info.dl_info_data | formatSize }}/{{ info.alltime_dl | formatSize }}]
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<v-divider vertical class="mx-2" v-if="!phoneLayout"/>
|
||||
<div class="icon-label">
|
||||
<v-icon
|
||||
:color=" info.up_info_speed > 0 ? 'warning' : null"
|
||||
>mdi-upload</v-icon>
|
||||
<span>
|
||||
{{ info.up_info_speed | formatSize }}/s
|
||||
<template v-if="info.up_rate_limit">
|
||||
({{ info.up_rate_limit | formatSize}}/s)
|
||||
</template>
|
||||
<template v-if="!phoneLayout">
|
||||
[{{ info.up_info_data | formatSize }}/{{ info.alltime_ul | formatSize }}]
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { mapState, mapGetters } from 'vuex';
|
||||
import api from '../Api';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
phoneLayout: Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
app: null,
|
||||
speedLimited: false,
|
||||
};
|
||||
},
|
||||
|
||||
filters: {
|
||||
connectionIcon(status: string) {
|
||||
const statusMap: any = {
|
||||
connected: 'server-network',
|
||||
firewalled: 'server-network-off',
|
||||
disconnected: 'security-network',
|
||||
};
|
||||
return statusMap[status];
|
||||
},
|
||||
connectionIconColor(status: string) {
|
||||
const statusMap: any = {
|
||||
connected: 'success',
|
||||
firewalled: 'warning',
|
||||
disconnected: 'error',
|
||||
};
|
||||
return statusMap[status];
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState({
|
||||
info(state: any) {
|
||||
return this.isDataReady ? state.mainData.server_state : null;
|
||||
},
|
||||
}),
|
||||
...mapGetters([
|
||||
'isDataReady',
|
||||
'allTorrents',
|
||||
]),
|
||||
totalSize() {
|
||||
return _.sumBy(this.allTorrents, 'size');
|
||||
},
|
||||
speedModeBind() {
|
||||
if (this.speedLimited) {
|
||||
return {
|
||||
class: 'speed-limited',
|
||||
color: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
class: null,
|
||||
color: 'success',
|
||||
};
|
||||
},
|
||||
topLayoutClass() {
|
||||
const v = this.phoneLayout;
|
||||
if (v) {
|
||||
return ['in-drawer', 'flex-column'];
|
||||
}
|
||||
|
||||
return ['mx-4', 'justify-space-between'];
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async getAppInfo() {
|
||||
let resp = await api.getAppVersion();
|
||||
const version = resp.data;
|
||||
|
||||
resp = await api.getApiVersion();
|
||||
const apiVersion = resp.data;
|
||||
|
||||
this.app = {
|
||||
version, apiVersion,
|
||||
};
|
||||
},
|
||||
async toggleSpeedLimitsMode() {
|
||||
this.speedLimited = !this.speedLimited;
|
||||
await api.toggleSpeedLimitsMode();
|
||||
},
|
||||
},
|
||||
|
||||
async created() {
|
||||
if (!this.isDataReady) {
|
||||
return;
|
||||
}
|
||||
this.speedLimited = this.info.use_alt_speed_limits;
|
||||
await this.getAppInfo();
|
||||
},
|
||||
|
||||
watch: {
|
||||
async isDataReady(v) {
|
||||
if (v && this.app === null) {
|
||||
await this.getAppInfo();
|
||||
}
|
||||
},
|
||||
'info.use_alt_speed_limits': function (v) {
|
||||
this.speedLimited = v;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.footer {
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
|
||||
.footer-row {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.v-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.speed-switch {
|
||||
font-size: inherit;
|
||||
width: 100%;
|
||||
|
||||
::v-deep {
|
||||
.v-input__prepend-outer {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.v-input__control {
|
||||
margin-left: 4px;
|
||||
width: 100%;
|
||||
|
||||
.v-input__slot {
|
||||
justify-content: space-between;
|
||||
|
||||
.v-input--selection-controls__input {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.v-label {
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.speed-limited {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.in-drawer {
|
||||
.no-icon {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,110 +1,106 @@
|
||||
<template>
|
||||
<v-dialog v-model="value" persistent width="25em">
|
||||
<v-card>
|
||||
<v-toolbar dark color="primary">
|
||||
<v-toolbar-title>Login</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-form
|
||||
ref="form"
|
||||
v-model="valid"
|
||||
>
|
||||
<v-container
|
||||
pa-0
|
||||
@keyup.enter.capture="submit"
|
||||
v-bind="{ [`grid-list-${$vuetify.breakpoint.name}`]: true }">
|
||||
<v-layout wrap>
|
||||
<v-flex>
|
||||
<v-text-field
|
||||
v-model="params.username"
|
||||
prepend-icon="mdi-account"
|
||||
label="Username"
|
||||
:rules="[v => !!v || 'Username is required']"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="params.password"
|
||||
prepend-icon="mdi-lock"
|
||||
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
@click:append="showPassword = !showPassword"
|
||||
label="Password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:rules="[v => !!v || 'Password is required']"
|
||||
required
|
||||
/>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</v-form>
|
||||
<v-alert
|
||||
type="warning"
|
||||
:value="loginError"
|
||||
v-text="loginError"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
@click="submit"
|
||||
color="primary"
|
||||
:disabled="!valid || submitting"
|
||||
:loading="submitting"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { api } from '../Api';
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
valid: false,
|
||||
submitting: false,
|
||||
showPassword: false,
|
||||
loginError: null,
|
||||
params: {
|
||||
username: null,
|
||||
password: null,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
async submit() {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(this.$refs.form as any).validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
let data;
|
||||
try {
|
||||
data = await api.login(this.params);
|
||||
|
||||
if (data === 'Ok.') {
|
||||
this.$emit('input', false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginError = data;
|
||||
} catch (e) {
|
||||
this.loginError = e.message
|
||||
}
|
||||
|
||||
this.submitting = false;
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<v-dialog v-model="value" persistent width="25em">
|
||||
<v-card>
|
||||
<v-toolbar dark color="primary">
|
||||
<v-toolbar-title>Login</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-form
|
||||
ref="form"
|
||||
v-model="valid"
|
||||
>
|
||||
<div class="pa-0"
|
||||
@keyup.enter.capture="submit"
|
||||
v-bind="{ [`grid-list-${$vuetify.breakpoint.name}`]: true }">
|
||||
<v-text-field
|
||||
v-model="params.username"
|
||||
prepend-icon="mdi-account"
|
||||
label="Username"
|
||||
:rules="[v => !!v || 'Username is required']"
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="params.password"
|
||||
prepend-icon="mdi-lock"
|
||||
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
@click:append="showPassword = !showPassword"
|
||||
label="Password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:rules="[v => !!v || 'Password is required']"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</v-form>
|
||||
<v-alert
|
||||
type="warning"
|
||||
:value="loginError"
|
||||
v-text="loginError"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
@click="submit"
|
||||
color="primary"
|
||||
:disabled="!valid || submitting"
|
||||
:loading="submitting"
|
||||
>
|
||||
Submit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import api from '../Api';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
valid: false,
|
||||
submitting: false,
|
||||
showPassword: false,
|
||||
loginError: null,
|
||||
params: {
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
async submit() {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(this.$refs.form as any).validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
let data;
|
||||
try {
|
||||
data = await api.login(this.params);
|
||||
|
||||
if (data === 'Ok.') {
|
||||
this.$emit('input', false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginError = data;
|
||||
} catch (e) {
|
||||
this.loginError = e.message;
|
||||
}
|
||||
|
||||
this.submitting = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
<template>
|
||||
<v-toolbar
|
||||
:clipped-left="$vuetify.breakpoint.lgAndUp"
|
||||
:scroll-off-screen="!$vuetify.breakpoint.lgAndUp"
|
||||
app
|
||||
>
|
||||
<v-toolbar-title class="headline" v-class:sm-and-down="$vuetify.breakpoint.smAndDown">
|
||||
<v-toolbar-side-icon @click.stop="toggle"></v-toolbar-side-icon>
|
||||
<span class="hidden-sm-and-down">qBittorrent Web UI</span>
|
||||
</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: Boolean,
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
this.$emit('input', !this.value);
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-toolbar__title {
|
||||
margin-left: -16px;
|
||||
width: 280px;
|
||||
|
||||
&.sm-and-down {
|
||||
margin-left: -12px;
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<v-app-bar
|
||||
:clipped-left="$vuetify.breakpoint.lgAndUp"
|
||||
:scroll-off-screen="!$vuetify.breakpoint.lgAndUp"
|
||||
app
|
||||
class="app-bar pl-2"
|
||||
>
|
||||
<v-app-bar-nav-icon @click="toggle" />
|
||||
<v-toolbar-title class="bar-title" v-class:sm-and-down="$vuetify.breakpoint.smAndDown">
|
||||
<img class="icon" src="/favicon.ico">
|
||||
<span class="title hidden-sm-and-down ml-3 mr-5">qBittorrent Web UI</span>
|
||||
</v-toolbar-title>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: Boolean,
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
this.$emit('input', !this.value);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-bar {
|
||||
.bar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-toolbar__title {
|
||||
margin-left: -16px;
|
||||
width: 280px;
|
||||
|
||||
&.sm-and-down {
|
||||
margin-left: -12px;
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,447 +1,458 @@
|
||||
<template>
|
||||
<v-container fluid v-class:phone-layout="$vuetify.breakpoint.xsOnly">
|
||||
<v-layout
|
||||
column
|
||||
v-show="hasSelected"
|
||||
class="toolbar"
|
||||
>
|
||||
<v-toolbar
|
||||
flat
|
||||
color="white"
|
||||
height="56px"
|
||||
class="elevation-2"
|
||||
>
|
||||
<v-checkbox class="shrink menu-check"
|
||||
:input-value="hasSelected"
|
||||
:indeterminate="!hasSelectedAll"
|
||||
primary
|
||||
hide-details
|
||||
@click.stop="selectedRows = []"
|
||||
></v-checkbox>
|
||||
<v-btn icon @click="confirmDelete" title="Delete">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
<v-divider vertical inset />
|
||||
<v-btn icon @click="resumeTorrents" title="Resume">
|
||||
<v-icon>mdi-play</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon @click="pauseTorrents" title="Pause">
|
||||
<v-icon>mdi-pause</v-icon>
|
||||
</v-btn>
|
||||
<v-divider vertical inset />
|
||||
<v-btn icon @click="showInfo()" title="Info" v-if="selectedRows.length <= 4">
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
</v-btn>
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ on }">
|
||||
<v-btn icon v-on="on" title="Category">
|
||||
<v-icon>mdi-folder</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list class="category-actions">
|
||||
<v-subheader @click.stop="">
|
||||
Set category
|
||||
</v-subheader>
|
||||
<v-list-tile
|
||||
v-for="(item, i) in allCategories"
|
||||
:key="i"
|
||||
@click="setTorrentsCategory(item.key)"
|
||||
>
|
||||
<v-list-tile-action>
|
||||
<v-icon>mdi-folder-open</v-icon>
|
||||
</v-list-tile-action>
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>
|
||||
{{ item.name }}
|
||||
</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
<v-divider />
|
||||
<v-list-tile @click="setTorrentsCategory('')">
|
||||
<v-list-tile-action>
|
||||
<v-icon>mdi-folder-remove</v-icon>
|
||||
</v-list-tile-action>
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>
|
||||
Reset
|
||||
</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-divider vertical inset />
|
||||
<v-btn icon @click="reannounceTorrents" title="Reannounce">
|
||||
<v-icon>mdi-bullhorn</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon @click="recheckTorrents" title="Recheck">
|
||||
<v-icon>mdi-backup-restore</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-divider />
|
||||
</v-layout>
|
||||
|
||||
|
||||
<v-layout
|
||||
column
|
||||
>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="torrents"
|
||||
item-key="hash"
|
||||
:hide-actions="torrents.length <= pagination.rowsPerPage"
|
||||
v-class:hide-headers="hasSelected"
|
||||
select-all
|
||||
:pagination.sync="pagination"
|
||||
v-model="selectedRows"
|
||||
class="table"
|
||||
>
|
||||
<template v-slot:items="row">
|
||||
<td>
|
||||
<v-checkbox
|
||||
v-model="row.selected"
|
||||
hide-details
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
:title="row.item.name"
|
||||
class="icon-label"
|
||||
@dblclick.prevent="showInfo(row.item)"
|
||||
>
|
||||
<v-icon :color="row.item.state | stateColor">{{ row.item.state | stateIcon }}</v-icon>
|
||||
{{ row.item.name }}
|
||||
</td>
|
||||
<td>{{ row.item.size | formatSize }}</td>
|
||||
<td>
|
||||
<v-progress-linear
|
||||
height="1.4em"
|
||||
:value="row.item.progress * 100"
|
||||
:color="row.item.state | stateColor(true)"
|
||||
class="text-xs-center ma-0"
|
||||
>
|
||||
<span :class="row.item.progress | progressColorClass">{{ row.item.progress | progress }}</span>
|
||||
</v-progress-linear>
|
||||
</td>
|
||||
<td>{{ row.item.state }}</td>
|
||||
<td>{{ row.item.num_seeds }}/{{ row.item.num_complete }}</td>
|
||||
<td>{{ row.item.num_leechs }}/{{ row.item.num_incomplete }}</td>
|
||||
<td>{{ row.item.dlspeed | formatNetworkSpeed }}</td>
|
||||
<td>{{ row.item.upspeed | formatNetworkSpeed }}</td>
|
||||
<td>{{ row.item.eta | formatDuration({dayLimit: 100}) }}</td>
|
||||
<td>{{ row.item.ratio.toFixed(2) }}</td>
|
||||
<td>
|
||||
<span :title="row.item.added_on | formatTimestamp">
|
||||
{{ row.item.added_on | formatAsDuration }} ago
|
||||
</span>
|
||||
</td>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-layout>
|
||||
|
||||
<confirm-delete-dialog v-if="toDelete.length" v-model="toDelete" />
|
||||
<info-dialog v-if="toShowInfo.length" v-model="toShowInfo" :tab="infoTab" @change="infoTab = $event" />
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import ConfirmDeleteDialog from './dialogs/ConfirmDeleteDialog.vue';
|
||||
import InfoDialog from './dialogs/InfoDialog.vue';
|
||||
import { mapState, mapGetters, mapMutations } from 'vuex';
|
||||
import _ from 'lodash';
|
||||
import { api } from '../Api';
|
||||
import { formatSize, formatDuration } from '../filters';
|
||||
import { torrentIsState } from '../utils';
|
||||
import { StateType } from '../consts';
|
||||
|
||||
function getStateInfo(state: string) {
|
||||
let icon;
|
||||
switch (state) {
|
||||
case 'metaDL':
|
||||
case 'allocating':
|
||||
case 'downloading':
|
||||
case 'forcedDL':
|
||||
icon = {
|
||||
icon: 'download',
|
||||
color: 'info',
|
||||
};
|
||||
break;
|
||||
case 'uploading':
|
||||
case 'forcedUP':
|
||||
icon = {
|
||||
icon: 'upload',
|
||||
color: 'info',
|
||||
};
|
||||
break;
|
||||
case 'stalledDL':
|
||||
icon = {
|
||||
icon: 'download',
|
||||
color: null,
|
||||
};
|
||||
break;
|
||||
case 'stalledUP':
|
||||
icon = {
|
||||
icon: 'upload',
|
||||
color: null,
|
||||
};
|
||||
break;
|
||||
case 'pausedDL':
|
||||
icon = {
|
||||
icon: 'pause',
|
||||
color: 'warning',
|
||||
};
|
||||
break;
|
||||
case 'pausedUP':
|
||||
icon = {
|
||||
icon: 'check',
|
||||
color: null,
|
||||
};
|
||||
break;
|
||||
case 'queuedDL':
|
||||
case 'queuedUP':
|
||||
icon = {
|
||||
icon: 'timer-sand',
|
||||
color: 'info',
|
||||
};
|
||||
break;
|
||||
case 'checkingDL':
|
||||
case 'checkingUP':
|
||||
case 'queuedForChecking':
|
||||
case 'checkingResumeData':
|
||||
case 'moving':
|
||||
icon = {
|
||||
icon: 'backup-restore',
|
||||
color: 'info',
|
||||
};
|
||||
break;
|
||||
case 'error':
|
||||
case 'unknown':
|
||||
case 'missingFiles':
|
||||
icon = {
|
||||
icon: 'alert',
|
||||
color: 'error',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw state;
|
||||
break;
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'torrents',
|
||||
|
||||
components: {
|
||||
ConfirmDeleteDialog,
|
||||
InfoDialog,
|
||||
},
|
||||
|
||||
data() {
|
||||
const headers = [
|
||||
{ text: 'Name', value: 'name', width: 'auto', class: 'th-name' },
|
||||
{ text: 'Size', value: 'size', width: '54px' },
|
||||
{ text: 'Progress', value: 'progress' },
|
||||
{ text: 'Status', value: 'state' },
|
||||
{ text: 'Seeds', value: 'num_complete' },
|
||||
{ text: 'Peers', value: 'num_incomplete' },
|
||||
{ text: 'DL Speed', value: 'dlspeed' },
|
||||
{ text: 'UP Speed', value: 'upspeed' },
|
||||
{ text: 'ETA', value: 'eta' },
|
||||
{ text: 'Ratio', value: 'ratio' },
|
||||
{ text: 'Added', value: 'added_on' },
|
||||
];
|
||||
|
||||
return {
|
||||
headers,
|
||||
selectedRows: [],
|
||||
toDelete: [],
|
||||
toShowInfo: [],
|
||||
infoTab: null,
|
||||
pagination: null,
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.pagination = this.$store.getters.config.pagination;
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'isDataReady',
|
||||
'allTorrents',
|
||||
'allCategories',
|
||||
'torrentGroupByCategory',
|
||||
'torrentGroupBySite',
|
||||
'torrentGroupByState',
|
||||
]),
|
||||
...mapState({
|
||||
filter(state, getters) {
|
||||
return getters.config.filter;
|
||||
},
|
||||
}),
|
||||
hasSelected() {
|
||||
return this.selectedRows.length;
|
||||
},
|
||||
selectedHashes() {
|
||||
return this.selectedRows.map(_.property('hash'));
|
||||
},
|
||||
torrents() {
|
||||
if (!this.isDataReady) {
|
||||
return [];
|
||||
}
|
||||
let list = this.allTorrents;
|
||||
if (this.filter.site !== null) {
|
||||
list = _.intersection(list, this.torrentGroupBySite[this.filter.site]);
|
||||
}
|
||||
if (this.filter.category !== null) {
|
||||
list = _.intersection(list, this.torrentGroupByCategory[this.filter.category]);
|
||||
}
|
||||
if (this.filter.state !== null) {
|
||||
list = _.intersection(list, this.torrentGroupByState[this.filter.state]);
|
||||
}
|
||||
|
||||
return list;
|
||||
},
|
||||
hasSelectedAll() {
|
||||
return this.hasSelected && this.selectedRows.length === Math.min(this.torrents.length, this.pagination.rowsPerPage)
|
||||
},
|
||||
},
|
||||
|
||||
filters: {
|
||||
progressColorClass(progress: number) {
|
||||
const color = progress >= 0.5 ? 'white' : 'black';
|
||||
return color + '--text';
|
||||
},
|
||||
formatNetworkSpeed(speed: number) {
|
||||
if (speed === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatSize(speed) + '/s';
|
||||
},
|
||||
stateIcon(state: string) {
|
||||
const item = getStateInfo(state);
|
||||
return 'mdi-' + item.icon;
|
||||
},
|
||||
stateColor(state: string, isProgress?: boolean) {
|
||||
const item = getStateInfo(state);
|
||||
if (!isProgress) {
|
||||
return item.color;
|
||||
}
|
||||
|
||||
return item.color || '#0008';
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapMutations([
|
||||
'updateConfig',
|
||||
]),
|
||||
confirmDelete() {
|
||||
this.toDelete = this.selectedRows;
|
||||
},
|
||||
showInfo(row?: any) {
|
||||
this.toShowInfo = row ? [row] : this.selectedRows;
|
||||
},
|
||||
async resumeTorrents() {
|
||||
await api.resumeTorrents(this.selectedHashes);
|
||||
},
|
||||
async pauseTorrents() {
|
||||
await api.pauseTorrents(this.selectedHashes);
|
||||
},
|
||||
async reannounceTorrents() {
|
||||
await api.reannounceTorrents(this.selectedHashes);
|
||||
},
|
||||
async recheckTorrents() {
|
||||
await api.recheckTorrents(this.selectedHashes);
|
||||
},
|
||||
async setTorrentsCategory(category: string) {
|
||||
await api.setTorrentsCategory(this.selectedHashes, category);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
pagination: {
|
||||
handler() {
|
||||
this.updateConfig({
|
||||
key: 'pagination',
|
||||
value: this.pagination,
|
||||
});
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
html {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .v-toolbar__content {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 0 80px;
|
||||
height: calc(100vh - 100px); // footer + toobar = 100px
|
||||
overflow-y: scroll;
|
||||
|
||||
&.phone-layout {
|
||||
height: calc(100vh - 56px); // toobar = 56px
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.category-actions .v-list__tile__action {
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.menu-check {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
::v-deep .v-datatable thead th, .v-datatable tbody td {
|
||||
padding: 0 2px !important;
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&:first-child {
|
||||
padding: 0 0 0 8px !important;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .v-datatable {
|
||||
// table-layout: fixed;
|
||||
}
|
||||
|
||||
::v-deep.hide-headers .v-datatable thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::v-deep .v-datatable thead th.th-name {
|
||||
// max-width: 100px;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="torrents" v-class:phone-layout="$vuetify.breakpoint.xsOnly">
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<v-toolbar
|
||||
flat
|
||||
color="white"
|
||||
height="40px"
|
||||
class="elevation-2"
|
||||
>
|
||||
<v-btn icon @click="confirmDelete" title="Delete" :disabled="selectedRows.length == 0">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
<v-divider vertical inset />
|
||||
<v-btn icon @click="resumeTorrents" title="Resume" :disabled="selectedRows.length == 0">
|
||||
<v-icon>mdi-play</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon @click="pauseTorrents" title="Pause" :disabled="selectedRows.length == 0">
|
||||
<v-icon>mdi-pause</v-icon>
|
||||
</v-btn>
|
||||
<v-divider vertical inset />
|
||||
<v-btn icon @click="showInfo()" title="Info" :disabled="selectedRows.length == 0 || selectedRows.length >= 3">
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
</v-btn>
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ on }">
|
||||
<v-btn icon v-on="on" title="Category" :disabled="selectedRows.length == 0">
|
||||
<v-icon>mdi-folder</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list class="category-actions">
|
||||
<v-subheader @click.stop="">
|
||||
Set category
|
||||
</v-subheader>
|
||||
<v-list-item
|
||||
v-for="(item, i) in allCategories"
|
||||
:key="i"
|
||||
@click="setTorrentsCategory(item.key)"
|
||||
>
|
||||
<v-list-item-action>
|
||||
<v-icon>mdi-folder-open</v-icon>
|
||||
</v-list-item-action>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{ item.name }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
<v-list-item @click="setTorrentsCategory('')">
|
||||
<v-list-item-action>
|
||||
<v-icon>mdi-folder-remove</v-icon>
|
||||
</v-list-item-action>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
Reset
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-divider vertical inset />
|
||||
<v-btn icon @click="reannounceTorrents" title="Reannounce" :disabled="selectedRows.length == 0">
|
||||
<v-icon>mdi-bullhorn</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon @click="recheckTorrents" title="Recheck" :disabled="selectedRows.length == 0">
|
||||
<v-icon>mdi-backup-restore</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-divider />
|
||||
</div>
|
||||
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="torrents"
|
||||
item-key="hash"
|
||||
fixed-header
|
||||
:hide-default-footer="torrents.length <= pageOptions.itemsPerPage"
|
||||
v-class:hide-headers="hasSelected"
|
||||
show-select
|
||||
:options.sync="pageOptions"
|
||||
v-model="selectedRows"
|
||||
class="table"
|
||||
:loading="loading"
|
||||
dense
|
||||
:footer-props="footerProps"
|
||||
>
|
||||
<template v-slot:item="row">
|
||||
<tr
|
||||
>
|
||||
<!-- @dblclick.prevent="showInfo(row.item)" -->
|
||||
<td>
|
||||
<v-checkbox
|
||||
:value="row.isSelected"
|
||||
@change="row.select"
|
||||
hide-details
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
:title="row.item.name"
|
||||
class="icon-label"
|
||||
>
|
||||
<v-icon :color="row.item.state | stateColor">{{ row.item.state | stateIcon }}</v-icon>
|
||||
<span class="torrent-title">{{ row.item.name }}</span>
|
||||
</td>
|
||||
<td>{{ row.item.size | formatSize }}</td>
|
||||
<td>
|
||||
<v-progress-linear
|
||||
height="1.4em"
|
||||
:value="row.item.progress * 100"
|
||||
:color="row.item.state | stateColor(true)"
|
||||
class="text-center ma-0"
|
||||
>
|
||||
<span :class="row.item.progress | progressColorClass">
|
||||
{{ row.item.progress | progress }}
|
||||
</span>
|
||||
</v-progress-linear>
|
||||
</td>
|
||||
<td>{{ row.item.state }}</td>
|
||||
<td>{{ row.item.num_seeds }}/{{ row.item.num_complete }}</td>
|
||||
<td>{{ row.item.num_leechs }}/{{ row.item.num_incomplete }}</td>
|
||||
<td>{{ row.item.dlspeed | formatNetworkSpeed }}</td>
|
||||
<td>{{ row.item.upspeed | formatNetworkSpeed }}</td>
|
||||
<td>{{ row.item.eta | formatDuration({dayLimit: 100}) }}</td>
|
||||
<td>{{ row.item.ratio.toFixed(2) }}</td>
|
||||
<td>
|
||||
<span :title="row.item.added_on | formatTimestamp">
|
||||
{{ row.item.added_on | formatAsDuration }} ago
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<confirm-delete-dialog v-if="toDelete.length" v-model="toDelete" />
|
||||
<info-dialog
|
||||
v-if="toShowInfo.length"
|
||||
v-model="toShowInfo"
|
||||
:tab="infoTab"
|
||||
@change="infoTab = $event" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { mapState, mapGetters, mapMutations } from 'vuex';
|
||||
import _ from 'lodash';
|
||||
import ConfirmDeleteDialog from './dialogs/ConfirmDeleteDialog.vue';
|
||||
import InfoDialog from './dialogs/InfoDialog.vue';
|
||||
import api from '../Api';
|
||||
import { formatSize, formatDuration } from '../filters';
|
||||
import { torrentIsState } from '../utils';
|
||||
import { StateType } from '../consts';
|
||||
|
||||
function getStateInfo(state: string) {
|
||||
let icon;
|
||||
switch (state) {
|
||||
case 'metaDL':
|
||||
case 'allocating':
|
||||
case 'downloading':
|
||||
case 'forcedDL':
|
||||
icon = {
|
||||
icon: 'download',
|
||||
color: 'info',
|
||||
};
|
||||
break;
|
||||
case 'uploading':
|
||||
case 'forcedUP':
|
||||
icon = {
|
||||
icon: 'upload',
|
||||
color: 'info',
|
||||
};
|
||||
break;
|
||||
case 'stalledDL':
|
||||
icon = {
|
||||
icon: 'download',
|
||||
color: null,
|
||||
};
|
||||
break;
|
||||
case 'stalledUP':
|
||||
icon = {
|
||||
icon: 'upload',
|
||||
color: null,
|
||||
};
|
||||
break;
|
||||
case 'pausedDL':
|
||||
icon = {
|
||||
icon: 'pause',
|
||||
color: 'warning',
|
||||
};
|
||||
break;
|
||||
case 'pausedUP':
|
||||
icon = {
|
||||
icon: 'check',
|
||||
color: null,
|
||||
};
|
||||
break;
|
||||
case 'queuedDL':
|
||||
case 'queuedUP':
|
||||
icon = {
|
||||
icon: 'timer-sand',
|
||||
color: 'info',
|
||||
};
|
||||
break;
|
||||
case 'checkingDL':
|
||||
case 'checkingUP':
|
||||
case 'queuedForChecking':
|
||||
case 'checkingResumeData':
|
||||
case 'moving':
|
||||
icon = {
|
||||
icon: 'backup-restore',
|
||||
color: 'info',
|
||||
};
|
||||
break;
|
||||
case 'error':
|
||||
case 'unknown':
|
||||
case 'missingFiles':
|
||||
icon = {
|
||||
icon: 'alert',
|
||||
color: 'error',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw Error('Unknown state');
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'torrents',
|
||||
|
||||
components: {
|
||||
ConfirmDeleteDialog,
|
||||
InfoDialog,
|
||||
},
|
||||
|
||||
data() {
|
||||
const headers = [
|
||||
{ text: 'Name', value: 'name' },
|
||||
{ text: 'Size', value: 'size' },
|
||||
{ text: 'Progress', value: 'progress' },
|
||||
{ text: 'Status', value: 'state' },
|
||||
{ text: 'Seeds', value: 'num_complete' },
|
||||
{ text: 'Peers', value: 'num_incomplete' },
|
||||
{ text: 'DL Speed', value: 'dlspeed' },
|
||||
{ text: 'UP Speed', value: 'upspeed' },
|
||||
{ text: 'ETA', value: 'eta' },
|
||||
{ text: 'Ratio', value: 'ratio' },
|
||||
{ text: 'Added', value: 'added_on' },
|
||||
];
|
||||
|
||||
const footerProps = {
|
||||
'items-per-page-options': [10, 20, 50, -1],
|
||||
};
|
||||
|
||||
return {
|
||||
headers,
|
||||
selectedRows: [],
|
||||
toDelete: [],
|
||||
toShowInfo: [],
|
||||
infoTab: null,
|
||||
pageOptions: null,
|
||||
footerProps,
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.pageOptions = this.$store.getters.config.pageOptions;
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'isDataReady',
|
||||
'allTorrents',
|
||||
'allCategories',
|
||||
'torrentGroupByCategory',
|
||||
'torrentGroupBySite',
|
||||
'torrentGroupByState',
|
||||
]),
|
||||
...mapState({
|
||||
filter(state, getters) {
|
||||
return getters.config.filter;
|
||||
},
|
||||
}),
|
||||
loading() {
|
||||
return !this.isDataReady;
|
||||
},
|
||||
hasSelected() {
|
||||
return this.selectedRows.length;
|
||||
},
|
||||
selectedHashes() {
|
||||
return this.selectedRows.map(_.property('hash'));
|
||||
},
|
||||
torrents() {
|
||||
if (!this.isDataReady) {
|
||||
return [];
|
||||
}
|
||||
let list = this.allTorrents;
|
||||
if (this.filter.site !== null) {
|
||||
list = _.intersection(list, this.torrentGroupBySite[this.filter.site]);
|
||||
}
|
||||
if (this.filter.category !== null) {
|
||||
list = _.intersection(list, this.torrentGroupByCategory[this.filter.category]);
|
||||
}
|
||||
if (this.filter.state !== null) {
|
||||
list = _.intersection(list, this.torrentGroupByState[this.filter.state]);
|
||||
}
|
||||
|
||||
return list;
|
||||
},
|
||||
hasSelectedAll() {
|
||||
return this.hasSelected && this.selectedRows.length
|
||||
=== Math.min(this.torrents.length, this.pageOptions.rowsPerPage);
|
||||
},
|
||||
},
|
||||
|
||||
filters: {
|
||||
progressColorClass(progress: number) {
|
||||
const color = progress >= 0.5 ? 'white' : 'black';
|
||||
return `${color}--text`;
|
||||
},
|
||||
formatNetworkSpeed(speed: number) {
|
||||
if (speed === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${formatSize(speed)}/s`;
|
||||
},
|
||||
stateIcon(state: string) {
|
||||
const item = getStateInfo(state);
|
||||
return `mdi-${item.icon}`;
|
||||
},
|
||||
stateColor(state: string, isProgress?: boolean) {
|
||||
const item = getStateInfo(state);
|
||||
if (!isProgress) {
|
||||
return item.color;
|
||||
}
|
||||
|
||||
return item.color || '#0008';
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapMutations([
|
||||
'updateConfig',
|
||||
]),
|
||||
confirmDelete() {
|
||||
this.toDelete = this.selectedRows;
|
||||
},
|
||||
showInfo(row?: any) {
|
||||
this.toShowInfo = row ? [row] : this.selectedRows;
|
||||
},
|
||||
async resumeTorrents() {
|
||||
await api.resumeTorrents(this.selectedHashes);
|
||||
},
|
||||
async pauseTorrents() {
|
||||
await api.pauseTorrents(this.selectedHashes);
|
||||
},
|
||||
async reannounceTorrents() {
|
||||
await api.reannounceTorrents(this.selectedHashes);
|
||||
},
|
||||
async recheckTorrents() {
|
||||
await api.recheckTorrents(this.selectedHashes);
|
||||
},
|
||||
async setTorrentsCategory(category: string) {
|
||||
await api.setTorrentsCategory(this.selectedHashes, category);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
pageOptions: {
|
||||
handler() {
|
||||
this.updateConfig({
|
||||
key: 'pageOptions',
|
||||
value: this.pageOptions,
|
||||
});
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
// loading() {
|
||||
// debugger;
|
||||
// },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// ::v-deep .v-toolbar__content {
|
||||
// padding-left: 8px;
|
||||
// }
|
||||
|
||||
.torrents {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
|
||||
.table {
|
||||
::v-deep thead th, td {
|
||||
white-space: nowrap;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::v-deep thead th .v-data-table__checkbox {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 13px;
|
||||
height: auto;
|
||||
|
||||
.v-input--checkbox {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
|
||||
::v-deep .v-input--selection-controls__input {
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.torrent-title {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 40em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .toolbar {
|
||||
// position: sticky;
|
||||
// top: 0;
|
||||
// z-index: 2;
|
||||
// }
|
||||
|
||||
// .category-actions .v-list__tile__action {
|
||||
// min-width: 40px;
|
||||
// }
|
||||
|
||||
// .menu-check {
|
||||
// padding: 0;
|
||||
// }
|
||||
|
||||
.icon-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// ::v-deep .v-datatable {
|
||||
// // table-layout: fixed;
|
||||
// }
|
||||
|
||||
// ::v-deep.hide-headers .v-datatable thead {
|
||||
// display: none;
|
||||
// }
|
||||
</style>
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
<template>
|
||||
<v-dialog :value="true" @input="closeDialog" :fullscreen="phoneLayout" width="40em">
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-delete</v-icon>
|
||||
<span>Delete torrents</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete the selected torrents from the transfer list?
|
||||
<ol class="torrents pt-4">
|
||||
<li v-for="(row, i) in torrents" :key="i">
|
||||
{{ row.name }}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<v-checkbox
|
||||
v-model="deleteFiles"
|
||||
prepend-icon="mdi-file-cancel"
|
||||
label="Also delete files"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn flat @click="closeDialog">Cancel</v-btn>
|
||||
<v-btn
|
||||
@click="submit"
|
||||
color="warning"
|
||||
:disabled="submitting"
|
||||
:loading="submitting"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { api } from '@/Api';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: Array,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
deleteFiles: false,
|
||||
submitting: false,
|
||||
torrents: [],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.torrents = this.value;
|
||||
},
|
||||
computed: {
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('input', []);
|
||||
},
|
||||
async submit() {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
|
||||
const hashed = this.torrents.map((t: any) => t.hash);
|
||||
await api.deleteTorrents(hashed, this.deleteFiles);
|
||||
|
||||
this.closeDialog();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.torrents {
|
||||
overflow: auto;
|
||||
}
|
||||
.v-dialog--fullscreen {
|
||||
.v-card__text {
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
|
||||
.v-card__actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<v-dialog :value="true" @input="closeDialog" :fullscreen="phoneLayout" width="40em">
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-delete</v-icon>
|
||||
<span>Delete torrents</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="pb-0">
|
||||
Are you sure you want to delete the selected torrents from the transfer list?
|
||||
<ol class="torrents pt-6">
|
||||
<li v-for="(row, i) in torrents" :key="i">
|
||||
{{ row.name }}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<v-checkbox
|
||||
v-model="deleteFiles"
|
||||
prepend-icon="mdi-file-cancel"
|
||||
label="Also delete files"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn text @click="closeDialog">Cancel</v-btn>
|
||||
<v-btn
|
||||
@click="submit"
|
||||
color="warning"
|
||||
:disabled="submitting"
|
||||
:loading="submitting"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import api from '@/Api';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
value: Array,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
deleteFiles: false,
|
||||
submitting: false,
|
||||
torrents: [],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.torrents = this.value;
|
||||
},
|
||||
computed: {
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('input', []);
|
||||
},
|
||||
async submit() {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
|
||||
const hashed = this.torrents.map((t: any) => t.hash);
|
||||
await api.deleteTorrents(hashed, this.deleteFiles);
|
||||
|
||||
this.closeDialog();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.torrents {
|
||||
overflow: auto;
|
||||
}
|
||||
.v-dialog--fullscreen {
|
||||
.v-card__text {
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
|
||||
.v-card__actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,149 +1,150 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:value="true"
|
||||
@input="closeDialog"
|
||||
:width="dialogWidth"
|
||||
:fullscreen="phoneLayout"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-alert-circle</v-icon>
|
||||
<span>Info</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-tabs v-model="mTab">
|
||||
<!-- <v-tab>
|
||||
General
|
||||
</v-tab> -->
|
||||
<v-tab href="#trackers">
|
||||
Trackers
|
||||
</v-tab>
|
||||
<v-tab href="#peers">
|
||||
Peers
|
||||
</v-tab>
|
||||
<!-- <v-tab>
|
||||
Content
|
||||
</v-tab> -->
|
||||
</v-tabs>
|
||||
<v-tabs-items :value="mTab" touchless>
|
||||
<v-tab-item value="trackers">
|
||||
<panel
|
||||
v-for="torrent in torrents"
|
||||
:key="torrent.hash"
|
||||
:title="torrent.name"
|
||||
:single="torrents.length === 1"
|
||||
>
|
||||
<trackers
|
||||
:hash="torrent.hash"
|
||||
:isActive="mTab === 'trackers'"
|
||||
/>
|
||||
</panel>
|
||||
</v-tab-item>
|
||||
<v-tab-item value="peers">
|
||||
<panel
|
||||
v-for="torrent in torrents"
|
||||
:key="torrent.hash"
|
||||
:title="torrent.name"
|
||||
:single="torrents.length === 1"
|
||||
>
|
||||
<peers
|
||||
:hash="torrent.hash"
|
||||
:isActive="mTab === 'peers'"
|
||||
/>
|
||||
</panel>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn flat @click="closeDialog">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue'
|
||||
import Trackers from './Trackers.vue';
|
||||
import Peers from './Peers.vue';
|
||||
import Panel from './Panel.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Trackers,
|
||||
Peers,
|
||||
Panel,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: Array,
|
||||
tab: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
torrents: [],
|
||||
mTab: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.torrents = this.value;
|
||||
this.mTab = this.tab;
|
||||
},
|
||||
computed: {
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
},
|
||||
dialogWidth() {
|
||||
return this.phoneLayout ? '100%' : '80%';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('input', []);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
mTab(v) {
|
||||
this.$emit('change', v);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .v-dialog {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
::v-deep .v-datatable thead th, ::v-deep .v-datatable tbody td {
|
||||
padding: 0 2px !important;
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&:first-child {
|
||||
padding: 0 0 0 8px !important;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen {
|
||||
.v-card__text {
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
|
||||
.v-card__actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<v-dialog
|
||||
:value="true"
|
||||
@input="closeDialog"
|
||||
:width="dialogWidth"
|
||||
:fullscreen="phoneLayout"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-alert-circle</v-icon>
|
||||
<span>Info</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-tabs v-model="mTab">
|
||||
<!-- <v-tab>
|
||||
General
|
||||
</v-tab> -->
|
||||
<v-tab href="#trackers">
|
||||
Trackers
|
||||
</v-tab>
|
||||
<v-tab href="#peers">
|
||||
Peers
|
||||
</v-tab>
|
||||
<!-- <v-tab>
|
||||
Content
|
||||
</v-tab> -->
|
||||
</v-tabs>
|
||||
<v-tabs-items :value="mTab" touchless>
|
||||
<v-tab-item value="trackers">
|
||||
<panel
|
||||
v-for="torrent in torrents"
|
||||
:key="torrent.hash"
|
||||
:title="torrent.name"
|
||||
:single="torrents.length === 1"
|
||||
>
|
||||
<trackers
|
||||
:hash="torrent.hash"
|
||||
:isActive="mTab === 'trackers'"
|
||||
/>
|
||||
</panel>
|
||||
</v-tab-item>
|
||||
<v-tab-item value="peers">
|
||||
<panel
|
||||
v-for="torrent in torrents"
|
||||
:key="torrent.hash"
|
||||
:title="torrent.name"
|
||||
:single="torrents.length === 1"
|
||||
>
|
||||
<peers
|
||||
:hash="torrent.hash"
|
||||
:isActive="mTab === 'peers'"
|
||||
/>
|
||||
</panel>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn text @click="closeDialog">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
import Trackers from './Trackers.vue';
|
||||
import Peers from './Peers.vue';
|
||||
import Panel from './Panel.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
Trackers,
|
||||
Peers,
|
||||
Panel,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: Array,
|
||||
tab: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
torrents: [],
|
||||
mTab: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.torrents = this.value;
|
||||
this.mTab = this.tab;
|
||||
},
|
||||
computed: {
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
},
|
||||
dialogWidth() {
|
||||
return this.phoneLayout ? '100%' : '80%';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('input', []);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
mTab(v) {
|
||||
this.$emit('change', v);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .v-dialog {
|
||||
max-width: 1100px;
|
||||
|
||||
.v-card__text {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
::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;
|
||||
}
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen {
|
||||
.v-card__text {
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
|
||||
.v-card__actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,112 +1,120 @@
|
||||
<template>
|
||||
<v-dialog :value="value" @input="$emit('input', $event)" scrollable :fullscreen="phoneLayout" :width="dialogWidth">
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-delta</v-icon>
|
||||
<span>Logs</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-progress-linear
|
||||
:indeterminate="true"
|
||||
v-if="!logs"
|
||||
/>
|
||||
<ol class="logs caption">
|
||||
<li v-for="(row, i) in logs" :key="i" :class="row.type | typeColor">
|
||||
[{{ row.type | formatType }} {{ row.timestamp / 1000 | formatTimestamp }}] <span v-html="row.message" />
|
||||
</li>
|
||||
</ol>
|
||||
<div ref="end" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn flat @click="closeDialog">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { api } from '@/Api';
|
||||
import { sleep } from '@/utils';
|
||||
import Taskable from '@/mixins/taskable';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [Taskable],
|
||||
|
||||
props: {
|
||||
value: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
logs: [],
|
||||
};
|
||||
},
|
||||
filters: {
|
||||
formatType(type: number) {
|
||||
const map: any = {
|
||||
1: 'N',
|
||||
2: 'I',
|
||||
4: 'W',
|
||||
8: 'C',
|
||||
};
|
||||
return map[type];
|
||||
},
|
||||
typeColor(type: number) {
|
||||
const map: any = {
|
||||
1: 'secondary--text',
|
||||
2: 'info--text',
|
||||
4: 'warn--text',
|
||||
8: 'error--text',
|
||||
};
|
||||
return map[type];
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
dialogWidth() {
|
||||
return this.$vuetify.breakpoint.smAndDown ? '100%' : '70%';
|
||||
},
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('input', false);
|
||||
},
|
||||
async getLogs() {
|
||||
const lastId = this.logs.length ? this.logs[this.logs.length - 1]['id'] : -1;
|
||||
const logs = await api.getLogs(lastId);
|
||||
|
||||
if (this.destory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logs.length) {
|
||||
this.logs = this.logs.concat(logs);
|
||||
|
||||
await this.$nextTick();
|
||||
|
||||
this.$refs.end.scrollIntoView();
|
||||
}
|
||||
|
||||
this.task = setTimeout(this.getLogs, 2000);
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
await this.getLogs();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logs {
|
||||
font-family: monospace;
|
||||
|
||||
li:not(:last-child) {
|
||||
line-height: 1.4em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<v-dialog
|
||||
:value="value"
|
||||
@input="$emit('input', $event)"
|
||||
scrollable
|
||||
:fullscreen="phoneLayout"
|
||||
:width="dialogWidth"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title
|
||||
class="headline grey lighten-4"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-delta</v-icon>
|
||||
<span>Logs</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-progress-linear
|
||||
class="mt-4"
|
||||
:indeterminate="true"
|
||||
v-if="!logs.length"
|
||||
/>
|
||||
<ol class="logs caption">
|
||||
<li v-for="(row, i) in logs" :key="i" :class="row.type | typeColor">
|
||||
[{{ row.type | formatType }} {{ row.timestamp / 1000 | formatTimestamp }}]
|
||||
<span v-html="row.message" />
|
||||
</li>
|
||||
</ol>
|
||||
<div ref="end" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn text @click="closeDialog">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import api from '@/Api';
|
||||
import { sleep } from '@/utils';
|
||||
import Taskable from '@/mixins/taskable';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [Taskable],
|
||||
|
||||
props: {
|
||||
value: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
logs: [],
|
||||
};
|
||||
},
|
||||
filters: {
|
||||
formatType(type: number) {
|
||||
const map: any = {
|
||||
1: 'N',
|
||||
2: 'I',
|
||||
4: 'W',
|
||||
8: 'C',
|
||||
};
|
||||
return map[type];
|
||||
},
|
||||
typeColor(type: number) {
|
||||
const map: any = {
|
||||
1: 'secondary--text',
|
||||
2: 'info--text',
|
||||
4: 'warn--text',
|
||||
8: 'error--text',
|
||||
};
|
||||
return map[type];
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
dialogWidth() {
|
||||
return this.$vuetify.breakpoint.smAndDown ? '100%' : '70%';
|
||||
},
|
||||
phoneLayout() {
|
||||
return this.$vuetify.breakpoint.xsOnly;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('input', false);
|
||||
},
|
||||
async getLogs() {
|
||||
const lastId = this.logs.length ? this.logs[this.logs.length - 1].id : -1;
|
||||
const logs = await api.getLogs(lastId);
|
||||
|
||||
if (this.destory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logs.length) {
|
||||
// this.logs = this.logs.concat(logs);
|
||||
|
||||
await this.$nextTick();
|
||||
|
||||
this.$refs.end.scrollIntoView();
|
||||
}
|
||||
|
||||
this.task = setTimeout(this.getLogs, 2000);
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
await this.getLogs();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logs {
|
||||
font-family: monospace;
|
||||
|
||||
li {
|
||||
line-height: 1.4em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
<template>
|
||||
<fieldset class="panel" v-if="!single">
|
||||
<legend v-text="title" />
|
||||
<div class="inner">
|
||||
<slot />
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="inner" v-else>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
single: Boolean,
|
||||
title: String,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.panel {
|
||||
margin-top: 1em;
|
||||
|
||||
.inner {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border-width: 1px;
|
||||
|
||||
legend {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<fieldset class="panel" v-if="!single">
|
||||
<legend v-text="title" />
|
||||
<div class="inner">
|
||||
<slot />
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="inner" v-else>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
single: Boolean,
|
||||
title: String,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.panel {
|
||||
margin-top: 1em;
|
||||
|
||||
.inner {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border-width: 1px;
|
||||
|
||||
legend {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,156 +1,156 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="peers"
|
||||
:hide-actions="true"
|
||||
>
|
||||
<template v-slot:items="row">
|
||||
<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>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
import { codeToFlag, isWindows } from '../../utils';
|
||||
import Taskable from '@/mixins/taskable';
|
||||
import { api } from '../../Api';
|
||||
import { formatSize } from '../../filters';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [Taskable],
|
||||
|
||||
props: {
|
||||
hash: String,
|
||||
isActive: Boolean,
|
||||
},
|
||||
data() {
|
||||
const 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' },
|
||||
];
|
||||
|
||||
return {
|
||||
headers,
|
||||
peersObj: null,
|
||||
rid: null,
|
||||
isWindows,
|
||||
};
|
||||
},
|
||||
filters: {
|
||||
networkSpeed(speed: number) {
|
||||
if (speed === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatSize(speed) + '/s';
|
||||
},
|
||||
networkSize(size: number) {
|
||||
if (size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatSize(size);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
peers() {
|
||||
return _.map(this.peersObj, (value, key) => {
|
||||
return _.merge({}, value, { key });
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
codeToFlag(code: string) {
|
||||
if (code) {
|
||||
return codeToFlag(code);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
async getPeers() {
|
||||
const resp = await api.getTorrentPeers(this.hash, this.rid);
|
||||
this.rid = resp.rid;
|
||||
|
||||
if (resp.full_update) {
|
||||
this.peersObj = resp.peers;
|
||||
} else {
|
||||
const tmp: any = _.cloneDeep(this.peersObj);
|
||||
if (resp.peers_removed) {
|
||||
for (const key of resp.peers_removed) {
|
||||
delete tmp[key];
|
||||
}
|
||||
}
|
||||
this.peersObj = _.merge(tmp, resp.peers);
|
||||
}
|
||||
|
||||
if (!this.isActive || this.destroy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.task = setTimeout(this.getPeers, 2000);
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
if (this.isActive) {
|
||||
await this.getPeers();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async isActive(v) {
|
||||
if (v) {
|
||||
await this.getPeers();
|
||||
} else {
|
||||
this.cancelTask();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .ip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.country-flag {
|
||||
width: 1.5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="peers"
|
||||
:hide-default-footer="true"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import Vue from 'vue';
|
||||
import { codeToFlag, isWindows } from '../../utils';
|
||||
import Taskable from '@/mixins/taskable';
|
||||
import api from '../../Api';
|
||||
import { formatSize } from '../../filters';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [Taskable],
|
||||
|
||||
props: {
|
||||
hash: String,
|
||||
isActive: Boolean,
|
||||
},
|
||||
data() {
|
||||
const 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' },
|
||||
];
|
||||
|
||||
return {
|
||||
headers,
|
||||
peersObj: null,
|
||||
rid: null,
|
||||
isWindows,
|
||||
};
|
||||
},
|
||||
filters: {
|
||||
networkSpeed(speed: number) {
|
||||
if (speed === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${formatSize(speed)}/s`;
|
||||
},
|
||||
networkSize(size: number) {
|
||||
if (size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatSize(size);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
peers() {
|
||||
return _.map(this.peersObj, (value, key) => _.merge({}, value, { key }));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
codeToFlag(code: string) {
|
||||
if (code) {
|
||||
return codeToFlag(code);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
async getPeers() {
|
||||
const resp = await api.getTorrentPeers(this.hash, this.rid);
|
||||
this.rid = resp.rid;
|
||||
|
||||
if (resp.full_update) {
|
||||
this.peersObj = resp.peers;
|
||||
} else {
|
||||
const tmp: any = _.cloneDeep(this.peersObj);
|
||||
if (resp.peers_removed) {
|
||||
for (const key of resp.peers_removed) {
|
||||
delete tmp[key];
|
||||
}
|
||||
}
|
||||
this.peersObj = _.merge(tmp, resp.peers);
|
||||
}
|
||||
|
||||
if (!this.isActive || this.destroy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.task = setTimeout(this.getPeers, 2000);
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
if (this.isActive) {
|
||||
await this.getPeers();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async isActive(v) {
|
||||
if (v) {
|
||||
await this.getPeers();
|
||||
} else {
|
||||
this.cancelTask();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .ip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.country-flag {
|
||||
width: 1.5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,94 +1,96 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="trackers"
|
||||
:hide-actions="true"
|
||||
>
|
||||
<template v-slot:items="row">
|
||||
<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>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { api } from '../../Api';
|
||||
import Taskable from '@/mixins/taskable';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [Taskable],
|
||||
|
||||
props: {
|
||||
hash: String,
|
||||
isActive: Boolean,
|
||||
},
|
||||
data() {
|
||||
const 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' },
|
||||
];
|
||||
|
||||
return {
|
||||
headers,
|
||||
trackers: [],
|
||||
};
|
||||
},
|
||||
filters: {
|
||||
formatTrackerStatus(status: number) {
|
||||
const map = [
|
||||
'Disabled',
|
||||
'Not contacted',
|
||||
'Working',
|
||||
'Updating',
|
||||
'Not working',
|
||||
];
|
||||
|
||||
return map[status];
|
||||
},
|
||||
formatTrackerNum(num: number) {
|
||||
if (num === -1) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
return num.toString();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async getTracker() {
|
||||
this.trackers = await api.getTorrentTracker(this.hash);
|
||||
if (!this.isActive || this.destroy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.task = setTimeout(this.getTracker, 5000);
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
if (this.isActive) {
|
||||
await this.getTracker();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async isActive(v) {
|
||||
if (v) {
|
||||
await this.getTracker();
|
||||
} else {
|
||||
this.cancelTask();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="trackers"
|
||||
:hide-default-footer="true"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import api from '../../Api';
|
||||
import Taskable from '@/mixins/taskable';
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [Taskable],
|
||||
|
||||
props: {
|
||||
hash: String,
|
||||
isActive: Boolean,
|
||||
},
|
||||
data() {
|
||||
const 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' },
|
||||
];
|
||||
|
||||
return {
|
||||
headers,
|
||||
trackers: [],
|
||||
};
|
||||
},
|
||||
filters: {
|
||||
formatTrackerStatus(status: number) {
|
||||
const map = [
|
||||
'Disabled',
|
||||
'Not contacted',
|
||||
'Working',
|
||||
'Updating',
|
||||
'Not working',
|
||||
];
|
||||
|
||||
return map[status];
|
||||
},
|
||||
formatTrackerNum(num: number) {
|
||||
if (num === -1) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
return num.toString();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async getTracker() {
|
||||
this.trackers = await api.getTorrentTracker(this.hash);
|
||||
if (!this.isActive || this.destroy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.task = setTimeout(this.getTracker, 5000);
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
if (this.isActive) {
|
||||
await this.getTracker();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async isActive(v) {
|
||||
if (v) {
|
||||
await this.getTracker();
|
||||
} else {
|
||||
this.cancelTask();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,96 +1,110 @@
|
||||
<template>
|
||||
<v-list-group
|
||||
v-model="model"
|
||||
:prepend-icon="model ? group.icon : group['icon-alt']"
|
||||
class="filter-group"
|
||||
>
|
||||
<template v-slot:activator>
|
||||
<v-list-tile>
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title v-class:primary--text="selected !== null">
|
||||
{{ group.title }}
|
||||
</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</template>
|
||||
<v-list-tile
|
||||
v-for="(child, i) in group.children"
|
||||
:key="i"
|
||||
v-class:primary--text="selected === child.key"
|
||||
@click.stop="select(child.key)"
|
||||
>
|
||||
<v-list-tile-action>
|
||||
<v-icon v-if="isFontIcon(child.icon)">{{ child.icon }}</v-icon>
|
||||
<div v-else>
|
||||
<v-img :src="child.icon" width='22px' height="22px" />
|
||||
</div>
|
||||
</v-list-tile-action>
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>
|
||||
<template v-if="child.append">
|
||||
<v-layout>
|
||||
{{ child.title }}
|
||||
<v-spacer />
|
||||
{{ child.append }}
|
||||
</v-layout>>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ child.title }}
|
||||
</template>
|
||||
</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</v-list-group>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { mapState, mapMutations } from 'vuex';
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
group: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
model: this.group.model,
|
||||
selected: null,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.selected = this.$store.getters.config.filter[this.group.select];
|
||||
},
|
||||
methods: {
|
||||
...mapMutations([
|
||||
'updateConfig',
|
||||
]),
|
||||
select(key: any) {
|
||||
this.selected = this.selected === key ? null : key;
|
||||
this.updateConfig({
|
||||
key: 'filter',
|
||||
value: {
|
||||
[this.group.select]: this.selected
|
||||
},
|
||||
});
|
||||
},
|
||||
isFontIcon(icon: string) {
|
||||
return icon.startsWith('mdi-');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .v-list__group__header__prepend-icon {
|
||||
padding-left: 20px;
|
||||
}
|
||||
::v-deep .v-list__group__header [role=listitem] {
|
||||
margin-left: -4px;
|
||||
}
|
||||
.v-list__tile__action {
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.filter-group ::v-deep .v-list__group__items .v-list__tile {
|
||||
height: 2.2em;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<v-list-group
|
||||
v-model="model"
|
||||
:prepend-icon="model ? group.icon : group['icon-alt']"
|
||||
class="filter-group"
|
||||
>
|
||||
<template v-slot:activator>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title v-class:primary--text="selected !== null">
|
||||
{{ group.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
<v-list-item
|
||||
v-for="(child, i) in group.children"
|
||||
:key="i"
|
||||
v-class:v-list-item--active="selected === child.key"
|
||||
@click.stop="select(child.key)"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon v-if="isFontIcon(child.icon)">{{ child.icon }}</v-icon>
|
||||
<div v-else>
|
||||
<v-img :src="child.icon" width='22px' height="22px" />
|
||||
</div>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
<template v-if="child.append">
|
||||
<div class="d-flex">
|
||||
{{ child.title }}
|
||||
<v-spacer />
|
||||
{{ child.append }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ child.title }}
|
||||
</template>
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { mapState, mapMutations } from 'vuex';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
group: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
model: this.group.model,
|
||||
selected: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.selected = this.$store.getters.config.filter[this.group.select];
|
||||
},
|
||||
methods: {
|
||||
...mapMutations([
|
||||
'updateConfig',
|
||||
]),
|
||||
select(key: any) {
|
||||
this.selected = this.selected === key ? null : key;
|
||||
this.updateConfig({
|
||||
key: 'filter',
|
||||
value: {
|
||||
[this.group.select]: this.selected,
|
||||
},
|
||||
});
|
||||
},
|
||||
isFontIcon(icon: string) {
|
||||
return icon.startsWith('mdi-');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-group {
|
||||
.v-list-item {
|
||||
min-height: 0;
|
||||
|
||||
.v-list-item__icon {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.v-list-item__content {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ::v-deep .v-list__group__header__prepend-icon {
|
||||
// padding-left: 20px;
|
||||
// }
|
||||
// ::v-deep .v-list__group__header [role=listitem] {
|
||||
// margin-left: -4px;
|
||||
// }
|
||||
// .v-list__tile__action {
|
||||
// padding-left: 6px;
|
||||
// }
|
||||
|
||||
// .filter-group ::v-deep .v-list__group__items .v-list__tile {
|
||||
// height: 2.2em;
|
||||
// }
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import sites from './sites';
|
||||
|
||||
export const SiteMap = sites;
|
||||
|
||||
export const enum StateType {
|
||||
Downloading = 'downloading',
|
||||
Seeding = 'seeding',
|
||||
Completed = 'completed',
|
||||
Resumed = 'resumed',
|
||||
Paused = 'pasued',
|
||||
Active = 'active',
|
||||
Inactive = 'inactive',
|
||||
Errored = 'errored',
|
||||
}
|
||||
|
||||
export const AllStateTypes = [
|
||||
StateType.Downloading,
|
||||
StateType.Seeding,
|
||||
StateType.Completed,
|
||||
StateType.Resumed,
|
||||
StateType.Paused,
|
||||
StateType.Active,
|
||||
StateType.Inactive,
|
||||
StateType.Errored,
|
||||
];
|
||||
import sites from './sites';
|
||||
|
||||
export const SiteMap = sites;
|
||||
|
||||
export const enum StateType {
|
||||
Downloading = 'downloading',
|
||||
Seeding = 'seeding',
|
||||
Completed = 'completed',
|
||||
Resumed = 'resumed',
|
||||
Paused = 'pasued',
|
||||
Active = 'active',
|
||||
Inactive = 'inactive',
|
||||
Errored = 'errored',
|
||||
}
|
||||
|
||||
export const AllStateTypes = [
|
||||
StateType.Downloading,
|
||||
StateType.Seeding,
|
||||
StateType.Completed,
|
||||
StateType.Resumed,
|
||||
StateType.Paused,
|
||||
StateType.Active,
|
||||
StateType.Inactive,
|
||||
StateType.Errored,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
Vue.directive('class', (el, binding) => {
|
||||
const clsName = binding.arg!;
|
||||
el.classList.toggle(clsName, binding.value);
|
||||
});
|
||||
import Vue from 'vue';
|
||||
|
||||
Vue.directive('class', (el, binding) => {
|
||||
const clsName = binding.arg!;
|
||||
el.classList.toggle(clsName, binding.value);
|
||||
});
|
||||
|
||||
217
src/filters.ts
217
src/filters.ts
@@ -1,109 +1,108 @@
|
||||
import dayjs from 'dayjs';
|
||||
import Vue from 'vue';
|
||||
|
||||
export function toPrecision(value: number, precision: number) {
|
||||
if (value >= (Math.pow(10, precision))) {
|
||||
return value.toString();
|
||||
} else if (value >= 1) {
|
||||
return value.toPrecision(precision);
|
||||
}
|
||||
|
||||
return value.toFixed(precision - 1);
|
||||
}
|
||||
|
||||
export function formatSize(value: number) {
|
||||
const units = 'KMGTP';
|
||||
let index = -1;
|
||||
|
||||
while (value >= 1000) {
|
||||
index++;
|
||||
value /= 1024;
|
||||
}
|
||||
|
||||
const unit = index < 0 ? 'B' : units[index] + 'iB';
|
||||
|
||||
return `${toPrecision(value, 3)} ${unit}`;
|
||||
}
|
||||
|
||||
Vue.filter('formatSize', formatSize);
|
||||
Vue.filter('size', formatSize);
|
||||
|
||||
export interface DurationOptions {
|
||||
dayLimit?: number;
|
||||
maxUnitSize?: number;
|
||||
}
|
||||
|
||||
export function formatDuration(value: number, options?: DurationOptions) {
|
||||
const minute = 60;
|
||||
const hour = minute * 60;
|
||||
const day = hour * 24;
|
||||
const year = day * 365;
|
||||
|
||||
const durations = [year, day, hour, minute, 1];
|
||||
const units = 'ydhms';
|
||||
|
||||
let index = 0;
|
||||
let unitSize = 0;
|
||||
const parts = [];
|
||||
|
||||
const defaultOptions: DurationOptions = {
|
||||
maxUnitSize: 1,
|
||||
dayLimit: 0,
|
||||
};
|
||||
|
||||
const opt = options ? Object.assign(defaultOptions, options) : defaultOptions;
|
||||
|
||||
if (opt.dayLimit && value >= opt.dayLimit * day) {
|
||||
return '∞';
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if ((opt.maxUnitSize && unitSize === opt.maxUnitSize) || index === durations.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const duration = durations[index];
|
||||
if (value < duration) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const result = Math.floor(value / duration);
|
||||
parts.push(result + units[index]);
|
||||
|
||||
value %= duration;
|
||||
index++;
|
||||
unitSize++;
|
||||
}
|
||||
|
||||
// if (unitSize < 2 && index !== durations.length) {
|
||||
// const result = Math.floor(value / durations[index]);
|
||||
// parts.push(result + units[index]);
|
||||
// }
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
Vue.filter('formatDuration', formatDuration);
|
||||
|
||||
Vue.filter('formatTimestamp', (timestamp: number) => {
|
||||
if (timestamp === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const m = dayjs.unix(timestamp);
|
||||
return m.format('YYYY-MM-DD HH:mm:ss');
|
||||
});
|
||||
|
||||
export function formatAsDuration(date: number, options?: DurationOptions) {
|
||||
const duration = (Date.now() / 1000) - date;
|
||||
return formatDuration(duration, options);
|
||||
}
|
||||
|
||||
Vue.filter('formatAsDuration', formatAsDuration);
|
||||
|
||||
export function formatProgress(progress: number) {
|
||||
progress = progress * 100;
|
||||
return toPrecision(progress, 3) + '%';
|
||||
}
|
||||
|
||||
Vue.filter('progress', formatProgress);
|
||||
import dayjs from 'dayjs';
|
||||
import Vue from 'vue';
|
||||
|
||||
export function toPrecision(value: number, precision: number) {
|
||||
if (value >= (10 ** precision)) {
|
||||
return value.toString();
|
||||
} if (value >= 1) {
|
||||
return value.toPrecision(precision);
|
||||
}
|
||||
|
||||
return value.toFixed(precision - 1);
|
||||
}
|
||||
|
||||
export function formatSize(value: number) {
|
||||
const units = 'KMGTP';
|
||||
let index = -1;
|
||||
|
||||
while (value >= 1000) {
|
||||
index++;
|
||||
// eslint-disable-next-line
|
||||
value /= 1024;
|
||||
}
|
||||
|
||||
const unit = index < 0 ? 'B' : `${units[index]}iB`;
|
||||
|
||||
return `${toPrecision(value, 3)} ${unit}`;
|
||||
}
|
||||
|
||||
Vue.filter('formatSize', formatSize);
|
||||
Vue.filter('size', formatSize);
|
||||
|
||||
export interface DurationOptions {
|
||||
dayLimit?: number;
|
||||
maxUnitSize?: number;
|
||||
}
|
||||
|
||||
export function formatDuration(value: number, options?: DurationOptions) {
|
||||
const minute = 60;
|
||||
const hour = minute * 60;
|
||||
const day = hour * 24;
|
||||
const year = day * 365;
|
||||
|
||||
const durations = [year, day, hour, minute, 1];
|
||||
const units = 'ydhms';
|
||||
|
||||
let index = 0;
|
||||
let unitSize = 0;
|
||||
const parts = [];
|
||||
|
||||
const defaultOptions: DurationOptions = {
|
||||
maxUnitSize: 1,
|
||||
dayLimit: 0,
|
||||
};
|
||||
|
||||
const opt = options ? Object.assign(defaultOptions, options) : defaultOptions;
|
||||
|
||||
if (opt.dayLimit && value >= opt.dayLimit * day) {
|
||||
return '∞';
|
||||
}
|
||||
|
||||
while ((!opt.maxUnitSize || unitSize !== opt.maxUnitSize) && index !== durations.length) {
|
||||
const duration = durations[index];
|
||||
if (value < duration) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const result = Math.floor(value / duration);
|
||||
parts.push(result + units[index]);
|
||||
|
||||
// eslint-disable-next-line
|
||||
value %= duration;
|
||||
index++;
|
||||
unitSize++;
|
||||
}
|
||||
|
||||
// if (unitSize < 2 && index !== durations.length) {
|
||||
// const result = Math.floor(value / durations[index]);
|
||||
// parts.push(result + units[index]);
|
||||
// }
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
Vue.filter('formatDuration', formatDuration);
|
||||
|
||||
Vue.filter('formatTimestamp', (timestamp: number) => {
|
||||
if (timestamp === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const m = dayjs.unix(timestamp);
|
||||
return m.format('YYYY-MM-DD HH:mm:ss');
|
||||
});
|
||||
|
||||
export function formatAsDuration(date: number, options?: DurationOptions) {
|
||||
const duration = (Date.now() / 1000) - date;
|
||||
return formatDuration(duration, options);
|
||||
}
|
||||
|
||||
Vue.filter('formatAsDuration', formatAsDuration);
|
||||
|
||||
export function formatProgress(progress: number) {
|
||||
// eslint-disable-next-line
|
||||
progress *= 100;
|
||||
return `${toPrecision(progress, 3)}%`;
|
||||
}
|
||||
|
||||
Vue.filter('progress', formatProgress);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import Vue from 'vue';
|
||||
import './plugins/vuetify';
|
||||
import vuetify from './plugins/vuetify';
|
||||
import store from './store';
|
||||
// import router from './router';
|
||||
import './filters';
|
||||
import './directives';
|
||||
import App from './App.vue';
|
||||
// import './registerServiceWorker';
|
||||
import 'roboto-fontface/css/roboto/roboto-fontface.css';
|
||||
import '@/assets/mdi.scss';
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
new Vue({
|
||||
store,
|
||||
render: (h) => h(App),
|
||||
vuetify,
|
||||
render: h => h(App),
|
||||
}).$mount('#app');
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
destory: false,
|
||||
task: 0,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
cancelTask() {
|
||||
if (this.task) {
|
||||
clearTimeout(this.task);
|
||||
this.task = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.destory = true;
|
||||
this.cancelTask();
|
||||
},
|
||||
});
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
destory: false,
|
||||
task: 0,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
cancelTask() {
|
||||
if (this.task) {
|
||||
clearTimeout(this.task);
|
||||
this.task = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.destory = true;
|
||||
this.cancelTask();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import Vue from 'vue';
|
||||
// tslint:disable-next-line
|
||||
import Vuetify from 'vuetify/lib';
|
||||
import 'vuetify/src/stylus/app.styl';
|
||||
// import zhHans from 'vuetify/src/locale/zh-Hans';
|
||||
|
||||
Vue.use(Vuetify, {
|
||||
iconfont: 'mdi',
|
||||
Vue.use(Vuetify);
|
||||
|
||||
export default new Vuetify({
|
||||
// lang: {
|
||||
// locales: { zhHans },
|
||||
// current: 'zh-Hans',
|
||||
// },
|
||||
icons: {
|
||||
iconfont: 'mdi',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@ if (process.env.NODE_ENV === 'production') {
|
||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||
ready() {
|
||||
console.log(
|
||||
'App is being served from cache by a service worker.\n' +
|
||||
'For more details, visit https://goo.gl/AFskqB',
|
||||
'App is being served from cache by a service worker.\n'
|
||||
+ 'For more details, visit https://goo.gl/AFskqB',
|
||||
);
|
||||
},
|
||||
registered() {
|
||||
|
||||
25
src/router.ts
Normal file
25
src/router.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Vue from 'vue';
|
||||
import Router from 'vue-router';
|
||||
// import Home from './views/Home.vue';
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
export default new Router({
|
||||
mode: 'history',
|
||||
base: process.env.BASE_URL,
|
||||
routes: [
|
||||
// {
|
||||
// path: '/',
|
||||
// name: 'home',
|
||||
// component: Home,
|
||||
// },
|
||||
// {
|
||||
// path: '/about',
|
||||
// name: 'about',
|
||||
// // route level code-splitting
|
||||
// // this generates a separate chunk (about.[hash].js) for this route
|
||||
// // which is lazy-loaded when the route is visited.
|
||||
// component: () => import(/* webpackChunkName: "about" */ './views/About.vue'),
|
||||
// },
|
||||
],
|
||||
});
|
||||
1
src/shims-vue.d.ts
vendored
1
src/shims-vue.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue;
|
||||
}
|
||||
|
||||
68
src/sites.ts
68
src/sites.ts
@@ -1,34 +1,34 @@
|
||||
export default {
|
||||
'tp.m-team.cc': {
|
||||
name: 'M-Team (Legacy)',
|
||||
icon: 'https://tp.m-team.cc/favicon.ico',
|
||||
},
|
||||
'pt.m-team.cc': {
|
||||
name: 'M-Team',
|
||||
icon: 'https://pt.m-team.cc/favicon.ico',
|
||||
},
|
||||
'pt.keepfrds.com': {
|
||||
name: 'FRDS',
|
||||
icon: 'https://pt.keepfrds.com/static/favicon-64x64.png',
|
||||
},
|
||||
'hdcmct.org': {
|
||||
name: 'CMCT',
|
||||
icon: 'https://hdcmct.org/favicon.ico',
|
||||
},
|
||||
'hdchina.org': {
|
||||
name: 'HDChina',
|
||||
icon: 'https://hdchina.org/favicon.ico',
|
||||
},
|
||||
'chdbits.co': {
|
||||
name: 'CHDBits',
|
||||
icon: 'https://chdbits.co/favicon.ico',
|
||||
},
|
||||
'hdhome.org': {
|
||||
name: 'HDHome',
|
||||
icon: 'https://hdhome.org/favicon.ico',
|
||||
},
|
||||
'u2.dmhy.org': {
|
||||
name: 'U2',
|
||||
icon: 'https://u2.dmhy.org/favicon.ico',
|
||||
},
|
||||
};
|
||||
export default {
|
||||
'tp.m-team.cc': {
|
||||
name: 'M-Team (Old)',
|
||||
icon: 'https://tp.m-team.cc/favicon.ico',
|
||||
},
|
||||
'pt.m-team.cc': {
|
||||
name: 'M-Team',
|
||||
icon: 'https://pt.m-team.cc/favicon.ico',
|
||||
},
|
||||
'pt.keepfrds.com': {
|
||||
name: 'FRDS',
|
||||
icon: 'https://pt.keepfrds.com/static/favicon-64x64.png',
|
||||
},
|
||||
'hdcmct.org': {
|
||||
name: 'CMCT',
|
||||
icon: 'https://hdcmct.org/favicon.ico',
|
||||
},
|
||||
'hdchina.org': {
|
||||
name: 'HDChina',
|
||||
icon: 'https://hdchina.org/favicon.ico',
|
||||
},
|
||||
'chdbits.co': {
|
||||
name: 'CHDBits',
|
||||
icon: 'https://chdbits.co/favicon.ico',
|
||||
},
|
||||
'hdhome.org': {
|
||||
name: 'HDHome',
|
||||
icon: 'https://hdhome.org/favicon.ico',
|
||||
},
|
||||
'u2.dmhy.org': {
|
||||
name: 'U2',
|
||||
icon: 'https://u2.dmhy.org/favicon.ico',
|
||||
},
|
||||
};
|
||||
|
||||
22
src/store.ts
22
src/store.ts
@@ -9,7 +9,7 @@ Vue.use(Vuex);
|
||||
const defaultConfig = {
|
||||
updateInterval: 2000,
|
||||
pagination: {
|
||||
rowsPerPage: 50,
|
||||
itemsPerPage: 50,
|
||||
},
|
||||
filter: {
|
||||
state: null,
|
||||
@@ -41,6 +41,7 @@ export default new Vuex.Store({
|
||||
preferences: null,
|
||||
},
|
||||
mutations: {
|
||||
/* eslint-disable no-param-reassign */
|
||||
updateMainData(state, payload) {
|
||||
state.rid = payload.rid;
|
||||
if (payload.full_update) {
|
||||
@@ -66,13 +67,14 @@ export default new Vuex.Store({
|
||||
state.preferences = payload;
|
||||
},
|
||||
updateConfig(state, payload) {
|
||||
const key = payload.key;
|
||||
const value = payload.value;
|
||||
const { key } = payload;
|
||||
const { value } = payload;
|
||||
const tmp = _.merge({}, state.userConfig[key], value);
|
||||
Vue.set(state.userConfig, key, tmp);
|
||||
|
||||
saveConfig(state.userConfig);
|
||||
},
|
||||
/* eslint-enable no-param-reassign */
|
||||
},
|
||||
getters: {
|
||||
config(state) {
|
||||
@@ -86,22 +88,20 @@ export default new Vuex.Store({
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.map(state.mainData.torrents, (value, key) => {
|
||||
return _.merge({}, value, { hash: key });
|
||||
});
|
||||
return _.map(state.mainData.torrents,
|
||||
(value, key) => _.merge({}, value, { hash: key }));
|
||||
},
|
||||
allCategories(state) {
|
||||
if (!state.mainData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const categories = _.map(state.mainData.categories, (value, key) => {
|
||||
return _.merge({}, value, { key });
|
||||
});
|
||||
return _.sortBy(categories, 'name')
|
||||
const categories = _.map(state.mainData.categories,
|
||||
(value, key) => _.merge({}, value, { key }));
|
||||
return _.sortBy(categories, 'name');
|
||||
},
|
||||
torrentGroupByCategory(state, getters) {
|
||||
return _.groupBy(getters.allTorrents, (torrent) => torrent.category);
|
||||
return _.groupBy(getters.allTorrents, torrent => torrent.category);
|
||||
},
|
||||
torrentGroupBySite(state, getters) {
|
||||
return _.groupBy(getters.allTorrents, (torrent) => {
|
||||
|
||||
133
src/utils.ts
133
src/utils.ts
@@ -1,65 +1,68 @@
|
||||
import _ from 'lodash';
|
||||
import { StateType } from './consts';
|
||||
|
||||
const dlState = ['downloading', 'metaDL', 'stalledDL', 'checkingDL', 'pausedDL', 'queuedDL', 'forceDL', 'allocating'];
|
||||
const upState = ['uploading', 'stalledUP', 'checkingUP', 'queuedUP', 'forceUP'];
|
||||
const completeState = ['uploading', 'stalledUP', 'checkingUP', 'pausedUP', 'queuedUP', 'forceUP'];
|
||||
const activeState = ['metaDL', 'downloading', 'forceDL', 'uploading', 'forcedUP', 'moving'];
|
||||
const errorState = ['error', 'missingFiles'];
|
||||
|
||||
export function torrentIsState(type: StateType, state: string) {
|
||||
let result;
|
||||
switch (type) {
|
||||
case StateType.Downloading: {
|
||||
result = dlState.includes(state);
|
||||
break;
|
||||
}
|
||||
case StateType.Seeding: {
|
||||
result = upState.includes(state);
|
||||
break;
|
||||
}
|
||||
case StateType.Completed: {
|
||||
result = completeState.includes(state);
|
||||
break;
|
||||
}
|
||||
case StateType.Resumed:
|
||||
case StateType.Paused: {
|
||||
const paused = state.startsWith('paused');
|
||||
result = type === StateType.Paused ? paused : !paused;
|
||||
break;
|
||||
}
|
||||
case StateType.Active:
|
||||
case StateType.Inactive: {
|
||||
const active = activeState.includes(state);
|
||||
result = type === StateType.Active ? active : !active;
|
||||
break;
|
||||
}
|
||||
case StateType.Errored: {
|
||||
result = errorState.includes(state);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function codeToFlag(code: string) {
|
||||
const magicNumber = 0x1F1A5;
|
||||
|
||||
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 const isWindows = navigator.userAgent.includes('Windows');
|
||||
import _ from 'lodash';
|
||||
import { StateType } from './consts';
|
||||
|
||||
const dlState = ['downloading', 'metaDL', 'stalledDL', 'checkingDL', 'pausedDL', 'queuedDL', 'forceDL', 'allocating'];
|
||||
const upState = ['uploading', 'stalledUP', 'checkingUP', 'queuedUP', 'forceUP'];
|
||||
const completeState = ['uploading', 'stalledUP', 'checkingUP', 'pausedUP', 'queuedUP', 'forceUP'];
|
||||
const activeState = ['metaDL', 'downloading', 'forceDL', 'uploading', 'forcedUP', 'moving'];
|
||||
const errorState = ['error', 'missingFiles'];
|
||||
|
||||
export function torrentIsState(type: StateType, state: string) {
|
||||
let result;
|
||||
switch (type) {
|
||||
case StateType.Downloading: {
|
||||
result = dlState.includes(state);
|
||||
break;
|
||||
}
|
||||
case StateType.Seeding: {
|
||||
result = upState.includes(state);
|
||||
break;
|
||||
}
|
||||
case StateType.Completed: {
|
||||
result = completeState.includes(state);
|
||||
break;
|
||||
}
|
||||
case StateType.Resumed:
|
||||
case StateType.Paused: {
|
||||
const paused = state.startsWith('paused');
|
||||
result = type === StateType.Paused ? paused : !paused;
|
||||
break;
|
||||
}
|
||||
case StateType.Active:
|
||||
case StateType.Inactive: {
|
||||
const active = activeState.includes(state);
|
||||
result = type === StateType.Active ? active : !active;
|
||||
break;
|
||||
}
|
||||
case StateType.Errored: {
|
||||
result = errorState.includes(state);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw Error('Invalid type');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function codeToFlag(code: string) {
|
||||
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 const isWindows = navigator.userAgent.includes('Windows');
|
||||
|
||||
19
tslint.json
19
tslint.json
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"defaultSeverity": "warning",
|
||||
"extends": [
|
||||
"tslint:recommended"
|
||||
],
|
||||
"linterOptions": {
|
||||
"exclude": [
|
||||
"node_modules/**"
|
||||
]
|
||||
},
|
||||
"rules": {
|
||||
"quotemark": [true, "single"],
|
||||
"indent": [true, "spaces", 2],
|
||||
"interface-name": false,
|
||||
"ordered-imports": false,
|
||||
"object-literal-sort-keys": false,
|
||||
"no-consecutive-blank-lines": false
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ module.exports = {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.1.2:8080',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user