Upgrade to vuetify v2.0

This commit is contained in:
CzBiX
2019-08-31 18:35:00 +08:00
parent d25235cd22
commit 2cb05babcd
39 changed files with 16358 additions and 2673 deletions

3
.browserslistrc Normal file
View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie <= 8

7
.editorconfig Normal file
View 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
View 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
View File

@@ -18,4 +18,4 @@ yarn-error.log*
*.ntvs*
*.njsproj
*.sln
*.sw*
*.sw?

View File

@@ -1,5 +1,5 @@
module.exports = {
presets: [
'@vue/app'
]
}
'@vue/app',
],
};

13569
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

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

View File

@@ -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();

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
];

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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');

View File

@@ -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();
},
});

View File

@@ -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',
},
});

View File

@@ -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
View 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
View File

@@ -1,4 +1,5 @@
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}

View File

@@ -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',
},
};

View File

@@ -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) => {

View File

@@ -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');

View File

@@ -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
}
}

View File

@@ -9,7 +9,7 @@ module.exports = {
proxy: {
'/api': {
target: 'http://192.168.1.2:8080',
}
}
}
}
},
},
},
};