perf: Rework Content tab (#940)

This commit is contained in:
Rémi Marseault 2023-07-13 15:46:37 +02:00 committed by GitHub
parent 373e825cca
commit 516f1d7913
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 499 additions and 287 deletions

View file

@ -0,0 +1,91 @@
<template>
<v-dialog v-model="dialog" scrollable max-width="750px" :content-class="phoneLayout ? 'rounded-0' : 'rounded-form'" :fullscreen="phoneLayout" @keydown.enter.prevent="rename">
<v-card>
<v-card-title class="pa-0">
<v-toolbar-title class="ma-4 primarytext--text">
<h3>{{ $t('modals.rename.title') }}</h3>
</v-toolbar-title>
</v-card-title>
<v-card-text>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="caption">{{ $t('modals.rename.oldPath') }}</v-list-item-title>
<v-list-item-subtitle>{{ oldName }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-text-field v-model="newName" id="newPathInput" :label="$t('modals.rename.newPath')" autofocus :prepend-inner-icon="mdiFile" />
</v-list-item>
</v-card-text>
<v-divider />
<v-card-actions class="justify-end">
<v-btn class="accent white--text elevation-0 px-4" @click="rename">
{{ $t('save') }}
</v-btn>
<v-btn class="error white--text elevation-0 px-4" @click="close">
{{ $t('cancel') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import Modal from '@/mixins/Modal'
import { mdiFile } from '@mdi/js'
import { FullScreenModal } from '@/mixins'
import qbit from '@/services/qbit'
import { defineComponent } from 'vue'
export default defineComponent({
name: 'RenameTorrentFileModal',
mixins: [Modal, FullScreenModal],
props: {
hash: String,
isFolder: Boolean,
oldName: String
},
data() {
return {
newName: '',
mdiFile
}
},
computed: {
torrentHash() {
return this.hash as string
}
},
created() {
this.newName = this.oldName as string
},
mounted() {
const input = document.getElementById('newPathInput') as HTMLInputElement
const startIndex = this.newName.lastIndexOf('/')
const endIndex = this.newName.lastIndexOf('.')
if (input) {
input.setSelectionRange(startIndex + 1, endIndex == -1 ? this.newName.length : endIndex)
}
},
methods: {
async rename() {
let result: Promise<void>
if (this.isFolder) {
result = qbit.renameFolder(this.torrentHash, this.oldName as string, this.newName)
} else {
result = qbit.renameFile(this.torrentHash, this.oldName as string, this.newName)
}
result.then(
() => this.close(),
() => this.$toast.error(this.$t('modals.rename.errorConflict'))
)
},
close() {
this.dialog = false
}
}
})
</script>

View file

@ -10,7 +10,7 @@
<v-container>
<v-row>
<v-col>
<v-text-field v-model="name" clearable :label="$t('modals.rename.torrentName')" autofocus :prepend-inner-icon="mdiFile" />
<v-text-field v-model="name" id="torrentNameInput" clearable :label="$t('modals.rename.torrentName')" autofocus :prepend-inner-icon="mdiFile" />
</v-col>
</v-row>
</v-container>
@ -37,7 +37,7 @@ import qbit from '@/services/qbit'
import {defineComponent} from 'vue'
export default defineComponent({
name: 'RenameModal',
name: 'RenameTorrentModal',
mixins: [Modal, FullScreenModal],
props: {
hash: String
@ -58,10 +58,13 @@ export default defineComponent({
created() {
this.name = this.torrent.name
},
mounted() {
const input = document.getElementById('torrentNameInput') as HTMLInputElement
if (input) {
input.select()
}
},
methods: {
urlDecode() {
this.name = decodeURIComponent(this.name)
},
async rename() {
await qbit.setTorrentName(this.hash, this.name)
this.close()

View file

@ -44,9 +44,11 @@
<v-checkbox v-model="settings.showShutdownButton" hide-details class="ma-0 pa-0" :label="$t('modals.settings.vueTorrent.general.showShutdownButton')" />
</v-list-item>
<v-divider class="mb-5" />
<v-list-item class="my-2">
<v-row>
<v-col cols="12" sm="6" class="mb-n4">
<v-col cols="12" sm="6" md="4" class="mb-n4">
<v-text-field
v-model="settings.refreshInterval"
type="number"
@ -55,18 +57,29 @@
:hint="$t('modals.settings.vueTorrent.general.refreshIntervalHint')"
:label="$t('modals.settings.vueTorrent.general.refreshInterval')" />
</v-col>
<v-col cols="12" sm="6">
<v-col cols="12" sm="6" md="4" class="mb-n4">
<v-text-field
v-model="settings.contentInterval"
type="number"
dense
outlined
:hint="$t('modals.settings.vueTorrent.general.contentIntervalHint')"
:label="$t('modals.settings.vueTorrent.general.contentInterval')" />
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model="settings.torrentPieceCountRenderThreshold"
type="number"
dense
outlined
:hint="$t('modals.settings.vueTorrent.general.torrentPieceCountRenderThresholdHint')"
hide-details
:label="$t('modals.settings.vueTorrent.general.torrentPieceCountRenderThreshold')" />
</v-col>
</v-row>
</v-list-item>
<v-divider class="my-5" />
<v-list-item class="mb-3">
<v-row>
<v-col cols="12" sm="6" md="3">

View file

@ -397,7 +397,7 @@ export default {
this.createModal('ChangeLocationModal', { hashes: this.multiple ? this.selected_torrents : [this.torrent.hash] })
},
rename() {
this.createModal('RenameModal', { hash: this.torrent.hash })
this.createModal('RenameTorrentModal', { hash: this.torrent.hash })
},
async reannounce() {
await qbit.reannounceTorrents(this.hashes)

View file

@ -1,35 +1,42 @@
<template>
<v-card flat>
<v-treeview v-model="selected" :items="fileTree" :open.sync="opened" activatable selectable item-key="fullName" open-all>
<template #prepend="{ item, open }">
<v-icon v-if="!item.icon">
<v-card flat :loading="loading">
<v-treeview
v-model="fileSelection"
:open.sync="openedItems"
:items="fileTree"
activatable
selectable
item-key="id"
@input="updateSelection"
>
<template v-slot:prepend="{ item: node, open }">
<v-icon v-if="node.type === 'root'">
{{ mdiFileTree }}
</v-icon>
<v-icon v-if="node.type === 'folder'">
{{ open ? mdiFolderOpen : mdiFolder }}
</v-icon>
<v-icon v-else>
{{ item.icon }}
<v-icon v-else-if="node.type === 'file'">
{{ node | fileIcon }}
</v-icon>
</template>
<template #label="{ item }">
<v-text-field v-if="item.editing" v-model="item.newName" autofocus />
<span class="item-name" v-else>{{ item.name }}</span>
<template #label="{ item: node }">
<span class="item-name">{{ node.type === 'root' ? $t('modals.detail.pageContent.rootNode') : node.name }}</span>
</template>
<template #append="{ item }">
<div v-if="!item.icon">
<span class="ml-4">{{ item.children.length }} Files</span>
<v-btn v-if="!item.editing" fab x-small class="accent white--text elevation-0 px-4 ml-2" @click="edit(item)">
<template #append="{ item: node }">
<div v-if="node.type === 'root'">
</div>
<div v-else-if="node.type === 'folder'">
<span class="ml-4">{{ node | nodeContent }}</span>
<v-btn fab x-small class="accent white--text elevation-0 px-4 ml-2" @click="renameNode(node)">
<v-icon>{{ mdiPencil }}</v-icon>
</v-btn>
<v-btn v-if="item.editing" fab x-small class="accent white--text elevation-0 px-4 ml-2" @click="renameFolder(item)">
<v-icon>{{ mdiContentSave }}</v-icon>
</v-btn>
<v-btn v-if="item.editing" fab x-small class="error white--text elevation-0 px-4 ml-2" @click="toggleEditing(item)">
<v-icon>{{ mdiClose }}</v-icon>
</v-btn>
</div>
<div v-else>
<span v-if="!$vuetify.breakpoint.xsOnly && !item.editing">[{{ item.size }}]</span>
<span v-if="!$vuetify.breakpoint.xsOnly && !item.editing" class="ml-4">{{ item.progress }}%</span>
<span v-if="!$vuetify.breakpoint.xsOnly && !item.editing" class="ml-4">[ {{ item.priority | priority }} ]</span>
<span v-if="!$vuetify.breakpoint.xsOnly">[{{ node.size | formatSize }}]</span>
<span v-if="!$vuetify.breakpoint.xsOnly" class="ml-4">{{ node.progress | progress }}</span>
<span v-if="!$vuetify.breakpoint.xsOnly" class="ml-4">[ {{ getNodePriority(node) }} ]</span>
<v-menu open-on-hover offset-y>
<template #activator="{ on }">
<v-btn fab x-small class="accent white--text elevation-0 px-4 ml-2" v-on="on">
@ -37,7 +44,8 @@
</v-btn>
</template>
<v-list>
<v-list-item v-for="prio in priority_options" :key="prio.value" link @click="setFilePrio(item.id, prio.value)">
<v-list-item v-for="prio in filePriorityOptions" :key="prio.value" link
@click="setFilePrio(node, prio.value)">
<v-icon>{{ prio.icon }}</v-icon>
<v-list-item-title class="caption">
{{ prio.name }}
@ -45,175 +53,192 @@
</v-list-item>
</v-list>
</v-menu>
<v-btn v-if="!item.editing" fab x-small class="accent white--text elevation-0 px-4 ml-2" @click="edit(item)">
<v-btn fab x-small class="accent white--text elevation-0 px-4 ml-2" @click="renameNode(node)">
<v-icon>{{ mdiPencil }}</v-icon>
</v-btn>
<v-btn v-if="item.editing" fab x-small class="accent white--text elevation-0 px-4 ml-2" @click="renameFile(item)">
<v-icon>{{ mdiContentSave }}</v-icon>
</v-btn>
<v-btn v-if="item.editing" fab x-small class="error white--text elevation-0 px-4 ml-2" @click="toggleEditing(item)">
<v-icon>{{ mdiClose }}</v-icon>
</v-btn>
</div>
</template>
</v-treeview>
</v-card>
</template>
<script>
<script lang="ts">
import {defineComponent} from 'vue'
import {mapGetters} from 'vuex'
import {
mdiArrowDown,
mdiArrowUp,
mdiCodeJson,
mdiFile,
mdiFileDocumentOutline,
mdiFileExcel,
mdiFileImage,
mdiFilePdfBox,
mdiFileTree,
mdiFolder,
mdiFolderOpen,
mdiLanguageHtml5,
mdiLanguageMarkdown,
mdiMovie,
mdiNodejs,
mdiPencil,
mdiPriorityHigh,
mdiPriorityLow,
mdiTrendingUp
} from '@mdi/js'
import {TreeFile, TreeFolder, TreeNode, TreeRoot} from '@/types/vuetorrent'
import {Priority} from '@/enums/qbit'
import qbit from '@/services/qbit'
import { treeify } from '@/helpers'
import { FullScreenModal } from '@/mixins'
import { mdiClose, mdiContentSave, mdiPencil, mdiFolderOpen, mdiFolder, mdiFile, mdiTrendingUp, mdiPriorityHigh, mdiArrowUp, mdiArrowDown, mdiPriorityLow } from '@mdi/js'
import Vue from 'vue'
import i18n from "@/plugins/i18n";
import {TorrentFile} from "@/types/qbit/models";
import {genFileTree} from "@/helpers";
import {General} from "@/mixins";
const FILE_PRIORITY_OPTIONS = [
{ name: 'Max', icon: mdiPriorityHigh, value: 7 },
{ name: 'High', icon: mdiArrowUp, value: 6 },
{ name: 'Normal', icon: mdiArrowDown, value: 1 },
{ name: 'Unwanted', icon: mdiPriorityLow, value: 0 }
]
export default {
name: 'Content',
filters: {
priority(value) {
const res = FILE_PRIORITY_OPTIONS.find(el => el.value === value)
return res ? res.name : 'undefined'
}
},
mixins: [FullScreenModal],
export default defineComponent({
name: "Content",
props: {
hash: String,
isActive: Boolean
},
mixins: [General],
data() {
return {
inited: false,
opened: null,
selected: [],
treeData: null,
priority_options: FILE_PRIORITY_OPTIONS,
timer: null as NodeJS.Timeout | null,
apiLock: false,
loading: false,
cachedFiles: [] as TorrentFile[],
fileTree: [{}] as [TreeRoot],
openedItems: [] as string[],
fileSelection: [] as number[],
filePriorityOptions: [
{name: 'Max', icon: mdiPriorityHigh, value: Priority.MAXIMAL},
{name: 'High', icon: mdiArrowUp, value: Priority.HIGH},
{name: 'Normal', icon: mdiArrowDown, value: Priority.NORMAL},
{name: 'Unwanted', icon: mdiPriorityLow, value: Priority.DO_NOT_DOWNLOAD}
],
mdiFolderOpen,
mdiFolder,
mdiFile,
mdiFileTree,
mdiTrendingUp,
mdiPencil,
mdiContentSave,
mdiClose
mdiPencil
}
},
computed: {
fileTree() {
if (this.treeData) {
return treeify(this.treeData)
}
return []
...mapGetters(['getContentInterval']),
torrentHash(): string {
return this.hash as string
}
},
watch: {
isActive(active) {
if (active) {
if (this.inited) {
this.getTorrentFiles()
} else {
this.initFiles()
this.inited = true
}
isActive(newValue: boolean) {
if (newValue) {
this.updateFileTree().then(() => this.openedItems.push(''))
this.timer = setInterval(this.updateFileTree, this.getContentInterval())
} else {
clearInterval(this.timer as NodeJS.Timeout)
}
},
selected(newValue, oldValue) {
this.changeFilePriorities(newValue, oldValue)
}
},
created() {},
beforeDestroy() {
clearInterval(this.timer as NodeJS.Timeout)
},
filters: {
fileIcon(file: TreeFile) {
const types: Record<string, string> = {
html: mdiLanguageHtml5,
js: mdiNodejs,
json: mdiCodeJson,
md: mdiLanguageMarkdown,
pdf: mdiFilePdfBox,
png: mdiFileImage,
txt: mdiFileDocumentOutline,
sub: mdiFileDocumentOutline,
idx: mdiFileDocumentOutline,
xls: mdiFileExcel,
xlsx: mdiFileExcel,
avi: mdiMovie,
mp4: mdiMovie,
mkv: mdiMovie,
mov: mdiMovie,
wmv: mdiMovie
}
const type = file.name.split('.').pop() || ''
return types[type] || mdiFile
},
nodeContent(node: TreeRoot | TreeFolder) {
let fileCount = 0
let folderCount = 0
for (const child of node.children) {
if (child.type === 'file') {
fileCount++
} else if (child.type === 'folder') {
folderCount++
}
}
const res = []
if (fileCount > 0) {
res.push(i18n.tc('modals.detail.pageContent.fileInfo', fileCount).toString())
}
if (folderCount > 0) {
res.push(i18n.tc('modals.detail.pageContent.folderInfo', folderCount).toString())
}
return res.join(', ')
}
},
methods: {
initFiles() {
this.getTorrentFiles().then(() => {
this.opened = []
this.selected = this.treeData.filter(file => file.priority !== 0).map(file => file.name)
})
getNodePriority(node: TreeFile) {
const res = this.filePriorityOptions.find(el => el.value === node.priority)
return res ? res.name : 'undefined'
},
async getTorrentFiles() {
const data = await qbit.getTorrentFiles(this.hash)
data.forEach((d, i) => {
d.id = i
d.name = d.name.replace('.unwanted/', '')
})
this.treeData = data
},
async changeFilePriorities(newValue, oldValue) {
if (newValue.length === oldValue.length) return
async updateSelection(newValue: number[]) {
const oldValue = this.cachedFiles.filter(f => f.priority !== Priority.DO_NOT_DOWNLOAD).map(f => f.index)
const filesToExclude = oldValue
.filter(f => !newValue.includes(f))
.map(name => this.treeData.find(f => f.name === name))
.filter(f => f.priority !== 0)
.map(f => f.id)
.filter(index => !newValue.includes(index))
.map(index => this.cachedFiles.find(f => f.index === index))
.filter(f => f.priority !== Priority.DO_NOT_DOWNLOAD)
.map(f => f.index)
const filesToInclude = newValue
.filter(f => !oldValue.includes(f))
.map(name => this.treeData.find(f => f.name === name))
.filter(f => f.priority === 0)
.map(f => f.id)
.filter(index => !oldValue.includes(index))
.map(index => this.cachedFiles.find(f => f.index === index))
.filter(f => f.priority === Priority.DO_NOT_DOWNLOAD)
.map(f => f.index)
if (filesToExclude.length) {
await qbit.setTorrentFilePriority(this.hash, filesToExclude, 0)
await qbit.setTorrentFilePriority(this.torrentHash, filesToExclude, Priority.DO_NOT_DOWNLOAD)
}
if (filesToInclude.length) {
await qbit.setTorrentFilePriority(this.hash, filesToInclude, 1)
}
if (filesToExclude.length || filesToInclude.length) {
await this.getTorrentFiles()
await qbit.setTorrentFilePriority(this.torrentHash, filesToInclude, Priority.NORMAL)
}
},
toggleEditing(item) {
item.editing = !item.editing
async renameNode(node: TreeNode) {
this.createModal('RenameTorrentFileModal', {
hash: this.torrentHash,
isFolder: node.type === 'folder',
oldName: node.fullName
})
},
edit(item) {
item.newName = item.name
this.toggleEditing(item)
async setFilePrio(file: TreeFile, prio: Priority) {
await qbit.setTorrentFilePriority(this.torrentHash, [file.index], prio)
},
renameFile(item) {
const lastPathSep = item.fullName.lastIndexOf('/')
const args = [this.hash]
async updateFileTree() {
if (this.apiLock) return
this.apiLock = true
this.loading = true
if (lastPathSep === -1) args.push(item.name, item.newName)
else {
const prefix = item.fullName.substring(0, lastPathSep)
args.push(`${prefix}/${item.name}`, `${prefix}/${item.newName}`)
}
this.cachedFiles = await qbit.getTorrentFiles(this.torrentHash)
this.fileTree = [await genFileTree(this.cachedFiles)]
this.fileSelection = this.cachedFiles.filter(f => f.priority !== Priority.DO_NOT_DOWNLOAD).map(f => f.index)
qbit.renameFile(...args).catch(() => Vue.$toast.error(this.$t('toast.renameFileFailed')))
item.name = item.newName
this.toggleEditing(item)
},
renameFolder(item) {
const lastPathSep = item.fullName.lastIndexOf('/')
const args = [this.hash]
if (lastPathSep === -1) args.push(item.name, item.newName)
else {
const prefix = item.fullName.substring(0, lastPathSep)
args.push(`${prefix}/${item.name}`, `${prefix}/${item.newName}`)
}
qbit.renameFolder(...args).catch(() => Vue.$toast.error(this.$t('toast.renameFolderFailed')))
item.name = item.newName
this.toggleEditing(item)
},
setFilePrio(fileId, priority) {
qbit.setTorrentFilePriority(this.hash, [fileId], priority).then(() => this.initFiles())
await this.$nextTick()
this.loading = false
this.apiLock = false
}
}
}
})
</script>
<style scoped lang="scss">
.item-name {
white-space: normal;
}
</style>

View file

@ -1,7 +1,12 @@
import * as _ from 'lodash'
import { isProduction } from './utils'
import { mdiLanguageHtml5, mdiFileDocumentOutline, mdiNodejs, mdiFilePdfBox, mdiFileExcel, mdiCodeJson, mdiFileImage, mdiMovie, mdiLanguageMarkdown, mdiFile } from '@mdi/js'
/**
* Format bytes to human readable string
* @param a {number}
* @param b {number}
* @return {string}
*/
export function formatBytes(a, b) {
if (a === 0) return '0 B'
const c = 1024
@ -12,32 +17,11 @@ export function formatBytes(a, b) {
return `${parseFloat((a / Math.pow(c, f)).toFixed(d))} ${e[f]}`
}
export function getIconForFileType(type) {
const types = {
html: mdiLanguageHtml5,
js: mdiNodejs,
json: mdiCodeJson,
md: mdiLanguageMarkdown,
pdf: mdiFilePdfBox,
png: mdiFileImage,
txt: mdiFileDocumentOutline,
sub: mdiFileDocumentOutline,
idx: mdiFileDocumentOutline,
xls: mdiFileExcel,
avi: mdiMovie,
mp4: mdiMovie,
mkv: mdiMovie
}
if (!types[type]) return mdiFile
return types[type]
}
export const isWindows = navigator.userAgent.includes('Windows')
export const isWindows = navigator.userAgent.includes('Windows');
/**
* @param {string} code
* Convert code to flag
* @param code {string}
* @return {{char: string, url: string}}
*/
export function codeToFlag(code) {
@ -55,77 +39,81 @@ export function codeToFlag(code) {
}
}
export function treeify(paths) {
let result = []
const level = { result }
/**
* Generates a file tree from a list of files
* @param files {TorrentFile[]}
* @return {TreeRoot}
*/
export function genFileTree(files) {
/** @type {TreeRoot} */
const rootNode = {
type: 'root',
name: '',
fullName: '',
id: '',
children: []
}
paths.forEach(path => {
path.name
.split('\\')
.join('/')
.split('/')
.reduce((r, name) => {
if (!r[name]) {
r[name] = { result: [] }
r.result.push(createFile(path, name, r[name].result))
for (const file of files) {
/** @type {TreeRoot | TreeFolder} */
let cursor = rootNode
file.name.replace('\\', '/').split('/').reduce((parentPath, nodeName) => {
const nextPath = parentPath === '' ? nodeName : parentPath + '/' + nodeName
if (file.name.endsWith(nodeName)) {
/** @type {TreeFile} */
const newFile = {
type: 'file',
name: nodeName,
fullName: nextPath,
id: file.index,
availability: file.availability,
index: file.index,
is_seed: file.is_seed,
priority: file.priority,
progress: file.progress,
size: file.size
}
cursor.children.push(newFile)
} else {
/** @type {TreeFolder | undefined} */
const folder = cursor.children.find(el => el.name === nodeName)
if (folder) {
cursor = folder
} else {
// if not found, create folder and set cursor to folder
/** @type {TreeFolder} */
const newFolder = {
type: 'folder',
name: nodeName,
fullName: nextPath,
id: nextPath,
children: []
}
cursor.children.push(newFolder)
cursor = newFolder
}
}
return r[name]
}, level)
})
// parse folders
result = result.map(el => parseFolder(el))
function parseFolder(el, parent) {
if (el.children.length !== 0) {
const folder = createFolder(parent, el.name, el.children)
folder.children = folder.children.map(child => parseFolder(child, folder))
return folder
}
return el
return nextPath
}, '')
}
return result
}
function createFile(data, name, children) {
return {
id: data.id,
name: name,
fullName: data.name,
progress: Math.round(data.progress * 100),
size: formatBytes(data.size),
icon: getIconForFileType(name.split('.').pop()),
priority: data.priority,
children: children,
type: 'file'
}
}
function createFolder(parent, name, children) {
children.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name)
else if (a.type === 'directory') return -1
else return 1
})
return {
name: name,
fullName: parent === undefined ? name : `${parent.fullName}/${name}`,
type: 'directory',
children: children
}
return rootNode
}
const urlRegExp = new RegExp(
/(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.\S{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.\S{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.\S{2,}|www\.[a-zA-Z0-9]+\.\S{2,})/gi
)
/**
* Split string by separating urls
* @param string
* @return {string[]}
*/
export function splitByUrl(string) {
const urls = string.match(urlRegExp)
/** @type {string[]} */
let resultArray = []
if (urls) {
@ -152,10 +140,19 @@ export function splitByUrl(string) {
return resultArray
}
/**
* Check if string contains url
* @param string {string}
* @return {boolean}
*/
export function stringContainsUrl(string) {
return urlRegExp.test(string)
}
/**
* Get version from import.meta
* @return {string}
*/
export function getVersion() {
if (isProduction()) {
return 'import.meta.env.VITE_PACKAGE_VERSION'
@ -165,6 +162,10 @@ export function getVersion() {
return 'undefined'
}
/**
* Get base url from import.meta
* @return {string}
*/
export function getBaseURL() {
if (isProduction()) {
return 'import.meta.env.BASE_URL'
@ -174,6 +175,11 @@ export function getBaseURL() {
return 'undefined'
}
/**
* Get domain from url
* @param string {string}
* @return {string}
*/
export function getDomainBody(string) {
const match = string.match(/:\/\/([^\/]+\.)?([^\/.]+)\.[^\/.:]+/i)
if (match != null && match.length > 2 && typeof match[2] === 'string' && match[2].length > 0) {
@ -184,6 +190,13 @@ export function getDomainBody(string) {
}
export class ArrayHelper {
/**
* Remove item from array
* @typedef T
* @param array {T[]}
* @param item {T | T[]} Item or array of items to remove
* @return {T[]}
*/
static remove(array, item) {
const toRemove = Array.isArray(item) ? item : [item]
_.remove(array, item => toRemove.indexOf(item) > -1)
@ -191,12 +204,24 @@ export class ArrayHelper {
return array
}
/**
* Concatenate two arrays together
* @typedef T
* @param a {T[]} First array
* @param b {T[]} Second array
* @return {T[]}
*/
static concat(a, b) {
return _.concat(a, b)
}
}
export class Hostname {
/**
* Get hostname from url
* @param url {string}
* @return {string}
*/
static get(url) {
const match = url.match(/:\/\/(www[0-9]?\.)?(.[^/:]+)/i)
if (match != null && match.length > 2 && typeof match[2] === 'string' && match[2].length > 0) {
@ -207,10 +232,19 @@ export class Hostname {
}
}
/**
* Check if user is on mac
* @return {boolean}
*/
export function isMac() {
return window.navigator.userAgent.toUpperCase().indexOf('MAC') >= 0
}
/**
* Check ctrl/cmd key
* @param e {KeyboardEvent}
* @return {boolean}
*/
export function doesCommand(e) {
return isMac() ? e.metaKey : e.ctrlKey
}

View file

@ -271,8 +271,9 @@
"showShutdownButton": "Show shutdown button",
"refreshInterval": "qBittorrent API refresh interval",
"refreshIntervalHint": "In milliseconds",
"contentInterval": "Torrent file content refresh interval",
"contentIntervalHint": "In milliseconds",
"torrentPieceCountRenderThreshold": "Torrent piece count to disable rendering",
"torrentPieceCountRenderThresholdHint": "In milliseconds",
"currentVersion": "Current Version",
"qbittorrentVersion": "QBittorrent Version",
"registerMagnet": "Register magnet links",
@ -684,6 +685,15 @@
"is_private": "Torrent Private",
"wasted_size": "Wasted"
},
"pageTrackers": {
"url": "URL",
"status": "Status",
"peers": "Peers",
"seeds": "Seeds",
"leeches": "Leeches",
"downloaded": "Downloaded",
"message": "Message"
},
"pagePeers": {
"ip": "IP",
"connection": "Connection",
@ -697,18 +707,14 @@
"relevance": "Relevance",
"files": "Files"
},
"pageContent": {
"rootNode": "(Root Node)",
"fileInfo": "No file | {n} file | {n} files",
"folderInfo": "No folder | {n} folder | {n} folders"
},
"tagsAndCategories": {
"subHeaderTag": "Available Tags:",
"subHeaderCategories": "Available Categories:"
},
"pageTrackers": {
"url": "URL",
"status": "Status",
"peers": "Peers",
"seeds": "Seeds",
"leeches": "Leeches",
"downloaded": "Downloaded",
"message": "Message"
}
},
"add": {
@ -727,7 +733,10 @@
},
"rename": {
"title": "Rename",
"torrentName": "Torrent Name"
"torrentName": "Torrent Name",
"oldPath": "Old Path",
"newPath": "New Path",
"errorConflict": "A file or folder with the same name already exists"
},
"sort": {
"title": "Sort Torrents",
@ -805,8 +814,7 @@
"categorySaved": "Category edited successfully!",
"feedSaved": "Feed saved successfully!",
"ruleSaved": "Rule saved!",
"renameFileFailed": "Unable to rename file",
"renameFolderFailed": "Unable to rename file",
"renameFailed": "Unable to rename node",
"copySuccess": "Text copied!",
"copyNotSupported": "Unable to copy, context isn't secured",
"pasteSuccess": "Text pasted!",

View file

@ -43,6 +43,7 @@ export const StoreStateSchema: JSONSchemaType<PersistentStoreState> = {
openSideBarOnStart: { type: 'boolean' },
showShutdownButton: { type: 'boolean' },
refreshInterval: { type: 'number' },
contentInterval: { type: 'number' },
torrentPieceCountRenderThreshold: { type: 'number' },
busyDesktopTorrentProperties: { $ref: '/schemas/desktopDashboardProperties' },
doneDesktopTorrentProperties: { $ref: '/schemas/desktopDashboardProperties' },
@ -68,6 +69,7 @@ export const StoreStateSchema: JSONSchemaType<PersistentStoreState> = {
'openSideBarOnStart',
'showShutdownButton',
'refreshInterval',
'contentInterval',
'torrentPieceCountRenderThreshold',
'busyDesktopTorrentProperties',
'doneDesktopTorrentProperties',

View file

@ -9,7 +9,7 @@ export default {
INIT_INTERVALS: async (store: Store<StoreState>) => {
store.state.intervals[0] = setInterval(() => {
store.commit('updateMainData')
}, store.state.webuiSettings.refreshInterval)
}, store.getters.getApiRefreshInterval())
},
LOGIN: async (store: Store<StoreState>, payload: LoginPayload) => {
const res = await qbit.login(payload)

View file

@ -37,5 +37,7 @@ export default {
return i18n.tc('dashboard.torrentsCount', state.filteredTorrentsCount)
}
},
getSearchPlugins: (state: StoreState) => () => state.searchPlugins
getSearchPlugins: (state: StoreState) => () => state.searchPlugins,
getApiRefreshInterval: (state: StoreState) => () => state.webuiSettings.refreshInterval,
getContentInterval: (state: StoreState) => () => state.webuiSettings.contentInterval,
}

View file

@ -136,6 +136,7 @@ export default new Vuex.Store<StoreState>({
openSideBarOnStart: true,
showShutdownButton: true,
refreshInterval: 2000,
contentInterval: 5000,
torrentPieceCountRenderThreshold: 5000,
busyDesktopTorrentProperties: JSON.parse(JSON.stringify(desktopPropertiesTemplate)),
doneDesktopTorrentProperties: JSON.parse(JSON.stringify(desktopPropertiesTemplate)),

View file

@ -3,7 +3,7 @@ import type { Priority } from '@/enums/qbit'
export default interface TorrentFile {
/** Percentage of file pieces currently available (percentage/100) */
availability: number
/** File index */
/** File index (starting at 0) */
index: number
/** True if file is seeding/complete */
is_seed: boolean
@ -15,6 +15,6 @@ export default interface TorrentFile {
priority: Priority
/** File progress (percentage/100) */
progress: number
/** File size (bytes) */
/** File size in bytes */
size: number
}

View file

@ -1,19 +1,46 @@
import {Priority} from "@/enums/qbit";
export interface TreeNode {
/** Represents the type of the node */
type: 'file' | 'folder' | 'root'
/** Represents only the file/folder name */
name: string
/** Represents the full path of the file/folder (name included) */
fullName: string
children: TreeNode[]
type: 'file' | 'directory'
/** Represents the ID of the node
* the ID is the file index for a TreeFile
* otherwise it's the node's fullName */
id: string | number
}
export interface TreeFile extends TreeNode {
id: number
progress: number
size: string
icon: string
priority: number
type: 'file'
/** File index */
id: number
/** file pieces currently available (percentage/100) */
availability: number
/** File index */
index: number
/** True if file is seeding/complete */
is_seed: boolean
/** File priority */
priority: Priority
/** File progress */
progress: number
/** File size in bytes */
size: number
}
export interface TreeFolder extends TreeNode {
type: 'directory'
type: 'folder'
id: string
children: TreeNode[]
}
export interface TreeRoot extends TreeNode {
type: 'root'
name: ''
fullName: ''
id: ''
children: TreeNode[]
}

View file

@ -29,6 +29,7 @@ export default interface WebUISettings {
openSideBarOnStart: boolean
showShutdownButton: boolean
refreshInterval: number
contentInterval: number
torrentPieceCountRenderThreshold: number
busyDesktopTorrentProperties: TorrentProperty[]
doneDesktopTorrentProperties: TorrentProperty[]

View file

@ -6,13 +6,14 @@ import type ModalTemplate from './ModalTemplate'
import type SortOptions from './SortOptions'
import type StoreState from './StoreState'
import type { PersistentStoreState } from './StoreState'
import type { TreeNode, TreeFile, TreeFolder } from './TreeObjects'
import type { TreeNode, TreeFile, TreeFolder, TreeRoot } from './TreeObjects'
import { TorrentProperty } from './WebUISettings'
import type Tracker from './Tracker'
export {
Category,
Feed,
FeedArticle,
FeedRule,
SearchStatus,
SearchResult,
@ -23,6 +24,7 @@ export {
TreeNode,
TreeFile,
TreeFolder,
TreeRoot,
TorrentProperty,
Tracker
}

View file

@ -17,7 +17,7 @@
<v-row class="ma-0 pa-0">
<v-tabs v-model="tab" align-with-title show-arrows background-color="primary">
<v-tabs-slider color="white"/>
<v-tabs-slider color="white" />
<v-tab class="white--text" href="#overview">
<h4>{{ $t('modals.detail.tabTitleOverview') }}</h4>
</v-tab>
@ -41,23 +41,22 @@
<v-card-text class="pa-0">
<v-tabs-items v-model="tab" touchless>
<v-tab-item eager value="overview">
<Overview v-if="torrent" :is-active="tab === 'overview'" :torrent="torrent"/>
<Overview v-if="torrent" :is-active="tab === 'overview'" :torrent="torrent" />
</v-tab-item>
<v-tab-item eager value="info">
<Info v-if="torrent" :is-active="tab === 'info'" :torrent="torrent"/>
<Info v-if="torrent" :is-active="tab === 'info'" :torrent="torrent" />
</v-tab-item>
<v-tab-item eager value="trackers">
<Trackers :is-active="tab === 'trackers'" :hash="hash"/>
<Trackers :is-active="tab === 'trackers'" :hash="hash" />
</v-tab-item>
<v-tab-item eager value="peers">
<DetailPeers :is-active="tab === 'peers'" :hash="hash"/>
<DetailPeers :is-active="tab === 'peers'" :hash="hash" />
</v-tab-item>
<v-tab-item eager value="content">
<Content :is-active="tab === 'content'" :hash="hash"/>
<Content :is-active="tab === 'content'" :hash="hash" />
</v-tab-item>
<v-tab-item eager value="tagsAndCategories">
<TorrentTagsAndCategories v-if="torrent" :torrent="torrent" :is-active="tab === 'tagsAndCategories'"
:hash="hash"/>
<TorrentTagsAndCategories v-if="torrent" :torrent="torrent" :is-active="tab === 'tagsAndCategories'" :hash="hash" />
</v-tab-item>
</v-tabs-items>
</v-card-text>
@ -66,15 +65,15 @@
</template>
<script lang="ts">
import {defineComponent} from 'vue'
import {mapGetters} from 'vuex'
import {Content, DetailPeers, Info, TorrentTagsAndCategories, Trackers} from '../components/TorrentDetail/Tabs'
import {mdiClose} from '@mdi/js'
import { mapGetters } from 'vuex'
import { Content, Info, DetailPeers, Trackers, TorrentTagsAndCategories } from '../components/TorrentDetail/Tabs'
import { mdiClose } from '@mdi/js'
import Overview from "@/components/TorrentDetail/Tabs/Overview.vue";
import {defineComponent} from 'vue'
export default defineComponent({
name: 'TorrentDetail',
components: {Overview, Content, Info, DetailPeers, Trackers, TorrentTagsAndCategories},
components: { Overview, Content, Info, DetailPeers, Trackers, TorrentTagsAndCategories },
data() {
return {
tab: null,
@ -82,10 +81,10 @@ export default defineComponent({
}
},
computed: {
...mapGetters(['getTorrent', 'getModals']),
...mapGetters(['getTorrent']),
torrent() {
//@ts-expect-error: TS2339: Property 'getTorrent' does not exist on type 'CreateComponentPublicInstance...'
return this.getTorrent(this.hash as string)
//@ts-expect-error: TS2339: Property 'getTorrent' does not exist on type 'CreateComponentPublicInstance...'.
return this.getTorrent(this.hash)
},
hash(): string {
return this.$route.params.hash
@ -104,8 +103,7 @@ export default defineComponent({
this.$router.back()
},
handleKeyboardShortcut(e: KeyboardEvent) {
//@ts-expect-error: TS2339: Property 'getModals' does not exist on type 'CreateComponentPublicInstance...'
if (e.key === 'Escape' && this.getModals().length === 0) {
if (e.key === 'Escape') {
this.close()
}
}

View file

@ -33,16 +33,21 @@ exports[`General > render correctly 1`] = `
<v-list-item-stub data-v-7da6d3e2=\\"\\" activeclass=\\"\\" tag=\\"div\\">
<v-checkbox-stub data-v-7da6d3e2=\\"\\" errorcount=\\"1\\" errormessages=\\"\\" messages=\\"\\" rules=\\"\\" successmessages=\\"\\" backgroundcolor=\\"\\" hidedetails=\\"true\\" ripple=\\"true\\" valuecomparator=\\"[Function]\\" indeterminateicon=\\"$checkboxIndeterminate\\" officon=\\"$checkboxOff\\" onicon=\\"$checkboxOn\\" class=\\"ma-0 pa-0\\"></v-checkbox-stub>
</v-list-item-stub>
<v-divider-stub data-v-7da6d3e2=\\"\\" class=\\"mb-5\\"></v-divider-stub>
<v-list-item-stub data-v-7da6d3e2=\\"\\" activeclass=\\"\\" tag=\\"div\\" class=\\"my-2\\">
<v-row-stub data-v-7da6d3e2=\\"\\" tag=\\"div\\">
<v-col-stub data-v-7da6d3e2=\\"\\" cols=\\"12\\" sm=\\"6\\" tag=\\"div\\" class=\\"mb-n4\\">
<v-col-stub data-v-7da6d3e2=\\"\\" cols=\\"12\\" sm=\\"6\\" md=\\"4\\" tag=\\"div\\" class=\\"mb-n4\\">
<v-text-field-stub data-v-7da6d3e2=\\"\\" errorcount=\\"1\\" errormessages=\\"\\" messages=\\"\\" rules=\\"\\" successmessages=\\"\\" backgroundcolor=\\"\\" dense=\\"true\\" loaderheight=\\"2\\" clearicon=\\"$clear\\" outlined=\\"true\\" type=\\"number\\"></v-text-field-stub>
</v-col-stub>
<v-col-stub data-v-7da6d3e2=\\"\\" cols=\\"12\\" sm=\\"6\\" tag=\\"div\\">
<v-col-stub data-v-7da6d3e2=\\"\\" cols=\\"12\\" sm=\\"6\\" md=\\"4\\" tag=\\"div\\" class=\\"mb-n4\\">
<v-text-field-stub data-v-7da6d3e2=\\"\\" errorcount=\\"1\\" errormessages=\\"\\" messages=\\"\\" rules=\\"\\" successmessages=\\"\\" backgroundcolor=\\"\\" dense=\\"true\\" loaderheight=\\"2\\" clearicon=\\"$clear\\" outlined=\\"true\\" type=\\"number\\"></v-text-field-stub>
</v-col-stub>
<v-col-stub data-v-7da6d3e2=\\"\\" cols=\\"12\\" sm=\\"6\\" md=\\"4\\" tag=\\"div\\">
<v-text-field-stub data-v-7da6d3e2=\\"\\" errorcount=\\"1\\" errormessages=\\"\\" messages=\\"\\" rules=\\"\\" successmessages=\\"\\" backgroundcolor=\\"\\" dense=\\"true\\" hidedetails=\\"true\\" loaderheight=\\"2\\" clearicon=\\"$clear\\" outlined=\\"true\\" type=\\"number\\"></v-text-field-stub>
</v-col-stub>
</v-row-stub>
</v-list-item-stub>
<v-divider-stub data-v-7da6d3e2=\\"\\" class=\\"my-5\\"></v-divider-stub>
<v-list-item-stub data-v-7da6d3e2=\\"\\" activeclass=\\"\\" tag=\\"div\\" class=\\"mb-3\\">
<v-row-stub data-v-7da6d3e2=\\"\\" tag=\\"div\\">
<v-col-stub data-v-7da6d3e2=\\"\\" cols=\\"12\\" sm=\\"6\\" md=\\"3\\" tag=\\"div\\">