feat(content): Rework content tab entirely (#1470)

This commit is contained in:
Rémi Marseault 2024-01-28 11:56:46 +01:00 committed by GitHub
parent 196aff6d60
commit 6758072296
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 828 additions and 486 deletions

View file

@ -39,7 +39,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
- name: Build Node.js cache
uses: actions/cache@v4

View file

@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
- name: Build Node.js cache
uses: actions/cache@v4

View file

@ -20,7 +20,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
- name: Build Node.js cache
uses: actions/cache@v4

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import RightClickMenuEntry from './RightClickMenuEntry.vue'
import { RightClickMenuEntryType } from '@/types/vuetorrent'
defineProps<{
menuData: RightClickMenuEntryType[]
}>()
const rightClickMenuVisible = defineModel<boolean>({ required: true })
</script>
<template>
<v-menu v-if="rightClickMenuVisible" v-model="rightClickMenuVisible" activator="parent" :close-on-content-click="true"
transition="slide-y-transition" scroll-strategy="none">
<v-list>
<slot name="top" />
<RightClickMenuEntry v-for="entry in menuData" v-bind="entry" />
</v-list>
</v-menu>
</template>
<style lang="scss">
</style>

View file

@ -1,21 +1,17 @@
<script setup lang="ts">
import { TRCMenuEntry } from '@/types/vuetorrent'
import { RightClickMenuEntryType } from '@/types/vuetorrent'
defineProps<{
text: string
icon?: string
action?: () => void
hidden?: boolean
disabled?: boolean
disabledText?: string
disabledIcon?: string
divider?: boolean
children?: TRCMenuEntry[]
}>()
const props = defineProps<RightClickMenuEntryType>()
const onClick = () => {
if (props.action) {
props.action()
}
}
</script>
<template>
<v-list-item class="px-3 pointer" :disabled="disabled" v-if="!hidden" @click="action">
<v-list-item class="px-3" :disabled="disabled" v-if="!hidden" @click="onClick">
<div class="d-flex">
<v-icon class="mr-2" v-if="disabled && disabledIcon">{{ disabledIcon }}</v-icon>
<v-icon class="mr-2" v-else-if="icon">{{ icon }}</v-icon>

View file

@ -0,0 +1,3 @@
import RightClickMenu from './RightClickMenu.vue'
export default RightClickMenu

View file

@ -2,20 +2,11 @@
import { formatSpeedUnit, formatSpeedValue } from '@/helpers'
import { useVueTorrentStore } from '@/stores'
defineProps({
icon: {
type: String,
required: true
},
color: {
type: String,
required: true
},
value: {
type: Number,
required: true
}
})
defineProps<{
icon: string
color: string
value: number
}>()
const vueTorrentStore = useVueTorrentStore()
</script>

View file

@ -1,21 +1,20 @@
<script setup lang="ts">
import RightClickMenuEntry from '@/components/Dashboard/TRC/RightClickMenuEntry.vue'
import RightClickMenu from '@/components/Core/RightClickMenu'
import ConfirmDeleteDialog from '@/components/Dialogs/ConfirmDeleteDialog.vue'
import MoveTorrentDialog from '@/components/Dialogs/MoveTorrentDialog.vue'
import RenameTorrentDialog from '@/components/Dialogs/RenameTorrentDialog.vue'
import ShareLimitDialog from '@/components/Dialogs/ShareLimitDialog.vue'
import SpeedLimitDialog from '@/components/Dialogs/SpeedLimitDialog.vue'
import { useDashboardStore, useDialogStore, useMaindataStore, usePreferenceStore, useTorrentStore } from '@/stores'
import { TRCMenuEntry } from '@/types/vuetorrent'
import { RightClickMenuEntryType } from '@/types/vuetorrent'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue3-toastify'
const props = defineProps<{
modelValue: boolean
defineProps<{
rightClickProperties: { isVisible: boolean; offset: [number, number] }
}>()
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const router = useRouter()
@ -25,11 +24,6 @@ const maindataStore = useMaindataStore()
const preferenceStore = usePreferenceStore()
const torrentStore = useTorrentStore()
const trcVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const isMultiple = computed(() => dashboardStore.selectedTorrents.length > 1)
const hashes = computed(() => dashboardStore.selectedTorrents)
const hash = computed(() => hashes.value[0])
@ -124,7 +118,7 @@ async function exportTorrents() {
const link = document.createElement('a')
link.href = url
link.style.opacity = '0'
link.setAttribute('download', `${hash}.torrent`)
link.setAttribute('download', `${ hash }.torrent`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
@ -132,7 +126,7 @@ async function exportTorrents() {
})
}
const menuData = computed<TRCMenuEntry[]>(() => [
const menuData = computed<RightClickMenuEntryType[]>(() => [
{
text: t('dashboard.right_click.advanced.title'),
icon: 'mdi-head-cog',
@ -288,48 +282,48 @@ const menuData = computed<TRCMenuEntry[]>(() => [
</script>
<template>
<v-menu v-if="trcVisible" v-model="trcVisible" activator="parent" :close-on-content-click="true" transition="slide-y-transition" scroll-strategy="none">
<v-list>
<v-list-item>
<div class="d-flex justify-space-around">
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-btn density="compact" variant="plain" icon="mdi-play" v-bind="props" @click="resumeTorrents" />
</template>
<span>Resume</span>
</v-tooltip>
<div
:style="`position: absolute; left: ${rightClickProperties.offset[0]}px; top: ${rightClickProperties.offset[1]}px;`">
<RightClickMenu v-model="rightClickProperties.isVisible" :menu-data="menuData">
<template v-slot:top>
<v-list-item>
<div class="d-flex justify-space-around">
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-btn density="compact" variant="plain" icon="mdi-play" v-bind="props" @click="resumeTorrents" />
</template>
<span>{{ $t('dashboard.right_click.top.resume') }}</span>
</v-tooltip>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-btn density="compact" variant="plain" icon="mdi-fast-forward" v-bind="props" @click="forceResumeTorrents" />
</template>
<span>Force Resume</span>
</v-tooltip>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-btn density="compact" variant="plain" icon="mdi-fast-forward" v-bind="props"
@click="forceResumeTorrents" />
</template>
<span>{{ $t('dashboard.right_click.top.force_resume') }}</span>
</v-tooltip>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-btn density="compact" variant="plain" icon="mdi-pause" v-bind="props" @click="pauseTorrents" />
</template>
<span>Pause</span>
</v-tooltip>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-btn density="compact" variant="plain" icon="mdi-pause" v-bind="props" @click="pauseTorrents" />
</template>
<span>{{ $t('dashboard.right_click.top.pause') }}</span>
</v-tooltip>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-btn color="red" density="compact" variant="plain" icon="mdi-delete-forever" v-bind="props" @click="deleteTorrents" />
</template>
<span>Delete</span>
</v-tooltip>
</div>
</v-list-item>
<RightClickMenuEntry v-for="entry in menuData" v-bind="entry" />
</v-list>
</v-menu>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-btn color="red" density="compact" variant="plain" icon="mdi-delete-forever" v-bind="props"
@click="deleteTorrents" />
</template>
<span>{{ $t('dashboard.right_click.top.delete') }}</span>
</v-tooltip>
</div>
</v-list-item>
</template>
</RightClickMenu>
</div>
</template>
<style scoped lang="scss">
.menu-scrollable {
max-height: 500px;
overflow: visible;
}
</style>
<style scoped>
</style>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useDialog } from '@/composables'
import { useMaindataStore } from '@/stores'
import { useContentStore } from '@/stores'
import { nextTick, onBeforeMount, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { VForm } from 'vuetify/components'
@ -14,7 +14,7 @@ const props = defineProps<{
const { isOpened } = useDialog(props.guid)
const { t } = useI18n()
const maindataStore = useMaindataStore()
const contentStore = useContentStore()
const form = ref<VForm>()
const input = ref<HTMLInputElement>()
@ -30,9 +30,9 @@ async function submit() {
if (!isFormValid.value) return
if (props.isFolder) {
await maindataStore.renameTorrentFolder(props.hash, props.oldName, formData.newName)
await contentStore.renameTorrentFolder(props.hash, props.oldName, formData.newName)
} else {
await maindataStore.renameTorrentFile(props.hash, props.oldName, formData.newName)
await contentStore.renameTorrentFile(props.hash, props.oldName, formData.newName)
}
close()
@ -65,7 +65,8 @@ onBeforeMount(() => {
<v-card-text>
<v-form v-model="isFormValid" ref="form" @submit.prevent>
<v-text-field v-if="oldName" :model-value="oldName" disabled :label="$t('dialogs.moveTorrentFile.oldName')" />
<v-text-field v-model="formData.newName" ref="input" :rules="rules" autofocus :label="$t('dialogs.moveTorrent.newPath')" @keydown.enter="submit" />
<v-text-field v-model="formData.newName" ref="input" :rules="rules" autofocus
:label="$t('dialogs.moveTorrent.newPath')" @keydown.enter="submit" />
</v-form>
</v-card-text>
<v-card-actions>

View file

@ -1,122 +0,0 @@
<script setup lang="ts">
import MoveTorrentFileDialog from '@/components/Dialogs/MoveTorrentFileDialog.vue'
import RootNode from '@/components/TorrentDetail/Content/RootNode.vue'
import { useTreeBuilder } from '@/composables'
import { FilePriority } from '@/constants/qbit'
import { useDialogStore, useMaindataStore, useVueTorrentStore } from '@/stores'
import { TorrentFile } from '@/types/qbit/models'
import { Torrent, TreeFile, TreeNode } from '@/types/vuetorrent'
import { useIntervalFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
const props = defineProps<{ torrent: Torrent; isActive: boolean }>()
const dialogStore = useDialogStore()
const maindataStore = useMaindataStore()
const { fileContentInterval } = storeToRefs(useVueTorrentStore())
const { pause: pauseTimer, resume: resumeTimer } = useIntervalFn(updateFileTree, fileContentInterval, {
immediate: false,
immediateCallback: true
})
const apiLock = ref(false)
const loading = ref(false)
const cachedFiles = ref<TorrentFile[]>([])
const { tree } = useTreeBuilder(cachedFiles)
const openedItems = ref(['(root)'])
const renameDialog = ref('')
const renamePayload = reactive({
hash: '',
isFolder: false,
oldName: ''
})
const fileSelection = computed({
get: () => cachedFiles.value.filter(file => file.priority !== FilePriority.DO_NOT_DOWNLOAD).map(file => file.index),
async set(newValue: number[]) {
const oldValue = cachedFiles.value.filter(f => f.priority !== FilePriority.DO_NOT_DOWNLOAD).map(f => f.index)
const filesToExclude = oldValue
.filter(index => !newValue.includes(index))
.map(index => cachedFiles.value.find(f => f.index === index))
.filter(f => f && f.priority !== FilePriority.DO_NOT_DOWNLOAD)
.map(f => (f as TorrentFile).index)
const filesToInclude = newValue
.filter(index => !oldValue.includes(index))
.map(index => cachedFiles.value.find(f => f.index === index))
.filter(f => f && f.priority === FilePriority.DO_NOT_DOWNLOAD)
.map(f => (f as TorrentFile).index)
if (filesToExclude.length) {
await maindataStore.setTorrentFilePriority(props.torrent.hash, filesToExclude, FilePriority.DO_NOT_DOWNLOAD)
}
if (filesToInclude.length) {
await maindataStore.setTorrentFilePriority(props.torrent.hash, filesToInclude, FilePriority.NORMAL)
}
await updateFileTree()
}
})
async function renameNode(node: TreeNode) {
renamePayload.hash = props.torrent.hash
renamePayload.isFolder = node.type === 'folder'
renamePayload.oldName = node.fullName
renameDialog.value = dialogStore.createDialog(MoveTorrentFileDialog, renamePayload)
}
async function setFilePriority(node: TreeFile, priority: FilePriority) {
await maindataStore.setTorrentFilePriority(props.torrent.hash, [node.index], priority)
await updateFileTree()
}
async function updateFileTree() {
if (apiLock.value) return
apiLock.value = true
loading.value = true
await nextTick()
cachedFiles.value = await maindataStore.fetchFiles(props.torrent.hash)
loading.value = false
apiLock.value = false
await nextTick()
}
watch(
() => props.isActive,
newValue => {
if (newValue) {
resumeTimer()
updateFileTree()
} else {
pauseTimer()
}
}
)
watch(
() => dialogStore.isDialogOpened(renameDialog.value),
v => {
if (!v) {
updateFileTree()
}
}
)
onMounted(() => {
resumeTimer()
})
</script>
<template>
<v-card :loading="loading" flat>
<RootNode v-model:opened="openedItems" v-model:selected="fileSelection" :root="tree" @renameFolder="renameNode" @renameFile="renameNode" @setFilePriority="setFilePriority" />
<!--
TODO: add treeview after merge
https://github.com/vuetifyjs/vuetify/issues/13518
-->
</v-card>
</template>
<style scoped></style>

View file

@ -0,0 +1,88 @@
<script setup lang="ts">
import { useContentStore } from '@/stores'
import { Torrent, TreeNode } from '@/types/vuetorrent'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useDisplay } from 'vuetify'
import ContentNode from './ContentNode.vue'
const props = defineProps<{ torrent: Torrent; isActive: boolean }>()
const { height: deviceHeight } = useDisplay()
const contentStore = useContentStore()
const { rightClickProperties, openedItems, flatTree, internalSelection } = storeToRefs(contentStore)
const height = computed(() => {
// 48px for the tabs and page title
// 64px for the toolbar
// 12px for the padding (top and bottom)
return deviceHeight.value - 48 * 2 - 64 - 12 * 2
})
async function onRightClick(e: MouseEvent | Touch, node: TreeNode) {
if (rightClickProperties.value.isVisible) {
rightClickProperties.value.isVisible = false
await nextTick()
}
Object.assign(rightClickProperties.value, {
isVisible: true,
offset: [e.pageX, e.pageY],
hash: props.torrent.hash
})
if (internalSelection.value.size <= 1) {
internalSelection.value = new Set([node.fullName])
}
}
watch(
() => props.isActive,
newValue => {
if (newValue) {
contentStore.resumeTimer()
contentStore.updateFileTree()
} else {
contentStore.pauseTimer()
}
}
)
onMounted(() => {
internalSelection.value.clear()
contentStore.resumeTimer()
})
onUnmounted(() => contentStore.$reset())
</script>
<template>
<v-card>
<v-virtual-scroll id="tree-root" :items="flatTree" :height="height" item-height="68" class="pa-2">
<template #default="{ item }">
<ContentNode :opened-items="openedItems"
:node="item"
@setFilePrio="(fileIdx, prio) => contentStore.setFilePriority(fileIdx, prio)"
@onRightClick="(e, node) => onRightClick(e, node)" />
</template>
</v-virtual-scroll>
</v-card>
</template>
<style lang="scss">
#_tree-root {
font-size: medium;
list-style-type: none;
div.v-virtual-scroll__item {
padding-top: 8px;
&:first-child {
padding-top: 0;
}
&:last-child {
padding-bottom: 8px;
}
}
}
</style>

View file

@ -0,0 +1,163 @@
<script setup lang="ts">
import { getFileIcon } from '@/constants/vuetorrent'
import { useI18n } from 'vue-i18n'
import { FilePriority } from '@/constants/qbit'
import { doesCommand, formatData } from '@/helpers'
import { useContentStore, useVueTorrentStore } from '@/stores'
import { TreeNode } from '@/types/vuetorrent'
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
const props = defineProps<{
node: TreeNode
openedItems: string[]
}>()
const emit = defineEmits<{
setFilePrio: [fileIdx: number[], prio: FilePriority]
onRightClick: [e: MouseEvent | Touch, node: TreeNode]
}>()
const folderColor = '#ffe476'
const { t } = useI18n()
const { mobile } = useDisplay()
const contentStore = useContentStore()
const vuetorrentStore = useVueTorrentStore()
const depth = computed(() => {
if (props.node.name === '(root)') return 0
const effectiveDepth = props.node.fullName.split('/').length
const depthStep = mobile.value ? 12 : 24
return effectiveDepth * depthStep
})
function openNode(e: Event, node: TreeNode) {
if (node.type === 'file') return
e.stopPropagation()
const index = props.openedItems.indexOf(node.fullName)
if (index === -1) {
props.openedItems.push(node.fullName)
} else {
props.openedItems.splice(index, 1)
}
}
async function toggleFileSelection(node: TreeNode) {
if (node.getPriority() === FilePriority.DO_NOT_DOWNLOAD) {
emit('setFilePrio', node.getChildrenIds(), FilePriority.NORMAL)
} else {
emit('setFilePrio', node.getChildrenIds(), FilePriority.DO_NOT_DOWNLOAD)
}
}
function toggleInternalSelection(e: { metaKey: boolean; ctrlKey: boolean }, node: TreeNode) {
if (doesCommand(e)) {
if (contentStore.internalSelection.has(node.fullName)) {
contentStore.internalSelection.delete(node.fullName)
} else {
contentStore.internalSelection.add(node.fullName)
}
} else {
contentStore.internalSelection = new Set([node.fullName])
}
}
function getNodeColor(node: TreeNode) {
if (node.getPriority() === FilePriority.DO_NOT_DOWNLOAD) {
return 'grey'
}
const progress = node.getProgress()
return progress === 1 ? 'green' : ''
}
function getNodeDeepCount(node: TreeNode) {
const [folderCount, fileCount] = node.getDeepCount()
const res = []
if (folderCount > 1) {
res.push(t('torrentDetail.content.folderInfo', folderCount - 1))
}
if (fileCount > 0) {
res.push(t('torrentDetail.content.fileInfo', fileCount))
}
return res.join(', ')
}
function getNodeSubtitle(node: TreeNode) {
const values = [formatData(node.getSize(), vuetorrentStore.useBinarySize)]
if (node.type === 'folder') {
values.push(getNodeDeepCount(node))
}
return values.join(' | ')
}
</script>
<template>
<div :class="['d-flex flex-column py-2 pr-3', (node.isSelected(contentStore.internalSelection)) ? 'selected' : '']"
:style="`padding-left: ${depth}px`"
@click.stop="toggleInternalSelection($event, node)"
@contextmenu="$emit('onRightClick', $event, node)">
<div class="d-flex">
<!-- Selection checkbox -->
<div class="d-flex align-center" @click.stop="toggleFileSelection(node)">
<v-icon v-if="node.isWanted() === null" :color="getNodeColor(node)" icon="mdi-checkbox-intermediate-variant" />
<v-icon v-else-if="node.isWanted()" :color="getNodeColor(node)" icon="mdi-checkbox-marked" />
<v-icon v-else :color="getNodeColor(node)" icon="mdi-checkbox-blank-outline" />
</div>
<!-- Node icon -->
<div class="d-flex align-center spacer" @click="openNode($event, node)">
<v-icon v-if="node.type === 'folder'">{{ openedItems.includes(node.fullName) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
<v-icon v-if="node.name === '(root)'" icon="mdi-file-tree" />
<v-icon v-else-if="node.type === 'file'" :icon="getFileIcon(node.name)" />
<v-icon v-else-if="openedItems.includes(node.fullName)" icon="mdi-folder-open" :color="folderColor" />
<v-icon v-else icon="mdi-folder" :color="folderColor" />
</div>
<!-- Node content -->
<div class="d-flex flex-column overflow-hidden text-no-wrap mr-3">
<div :class="`text-${getNodeColor(node)}`">{{ node.name }}</div>
<div class="text-grey">
{{ getNodeSubtitle(node) }}
</div>
</div>
<v-spacer />
<!-- Priority icon -->
<div class="d-flex align-center">
<v-icon v-if="node.getPriority() === FilePriority.MAXIMAL" color="error">mdi-arrow-up</v-icon>
<v-icon v-else-if="node.getPriority() === FilePriority.HIGH" color="warning">mdi-arrow-top-right</v-icon>
<v-icon v-else-if="node.getPriority() === FilePriority.NORMAL">mdi-minus</v-icon>
<v-icon v-else-if="node.getPriority() === FilePriority.MIXED">mdi-tilde</v-icon>
<v-icon v-else-if="node.getPriority() === FilePriority.DO_NOT_DOWNLOAD" color="grey">mdi-cancel</v-icon>
</div>
</div>
<v-progress-linear :model-value="node.getProgress()" :max="1" :color="getNodeColor(node)" rounded="sm" />
</div>
</template>
<style scoped lang="scss">
.spacer {
margin-left: 8px;
margin-right: 16px;
}
.v-theme--darkTheme .selected {
background-color: rgb(var(--v-theme-surface));
filter: brightness(135%);
}
.v-theme--lightTheme .selected {
background-color: rgb(var(--v-theme-surface));
filter: brightness(75%);
}
</style>

View file

@ -1,56 +0,0 @@
<script setup lang="ts">
import { FilePriority } from '@/constants/qbit'
import { getFileIcon } from '@/constants/vuetorrent'
import { formatData, formatPercent } from '@/helpers'
import { useVueTorrentStore } from '@/stores'
import { TreeFile } from '@/types/vuetorrent'
import { useI18n } from 'vue-i18n'
defineProps<{
node: TreeFile
}>()
defineEmits<{
renameFile: [node: TreeFile]
setFilePriority: [node: TreeFile, priority: FilePriority]
}>()
const { t } = useI18n()
const vuetorrentStore = useVueTorrentStore()
const filePriorityOptions = [
{ name: t('constants.file_priority.max'), icon: 'mdi-priority-high', value: FilePriority.MAXIMAL },
{ name: t('constants.file_priority.high'), icon: 'mdi-arrow-up', value: FilePriority.HIGH },
{ name: t('constants.file_priority.normal'), icon: 'mdi-arrow-down', value: FilePriority.NORMAL },
{ name: t('constants.file_priority.unwanted'), icon: 'mdi-priority-low', value: FilePriority.DO_NOT_DOWNLOAD }
]
function getNodePriority(node: TreeFile) {
return filePriorityOptions.find(el => el.value === node.priority)?.name || ''
}
</script>
<template>
<v-list-item :title="node.name" :value="node.index" :prepend-icon="getFileIcon(node.name)">
<template v-slot:append>
<span class="mr-2">[ {{ formatData(node.size, vuetorrentStore.useBinarySize) }} ]</span>
<span class="mr-2">{{ formatPercent(node.progress) }}</span>
<span class="mr-4">[ {{ getNodePriority(node) }} ]</span>
<v-menu open-on-hover open-on-click open-delay="0" close-delay="5">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" class="mr-2" color="accent" size="x-small" icon="mdi-trending-up" />
</template>
<v-list>
<v-list-item v-for="prio in filePriorityOptions" @click="$emit('setFilePriority', node, prio.value)">
<v-list-item-title>
<v-icon>{{ prio.icon }}</v-icon>
<span class="ml-2">{{ prio.name }}</span>
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn color="accent" size="x-small" icon="mdi-pencil" @click.stop="$emit('renameFile', node)" />
</template>
</v-list-item>
</template>
<style scoped></style>

View file

@ -1,73 +0,0 @@
<script setup lang="ts">
import { FilePriority } from '@/constants/qbit'
import { TreeFile, TreeFolder, TreeRoot } from '@/types/vuetorrent'
import { useI18n } from 'vue-i18n'
import FileNode from './FileNode.vue'
defineProps<{
node: TreeRoot | TreeFolder
}>()
defineEmits<{
renameFolder: [node: TreeFolder]
renameFile: [node: TreeFile]
setFilePriority: [node: TreeFile, priority: FilePriority]
}>()
const { t } = useI18n()
function getNodeDescription(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(t('torrentDetail.content.fileInfo', fileCount))
}
if (folderCount > 0) {
res.push(t('torrentDetail.content.folderInfo', folderCount))
}
return res.join(', ')
}
</script>
<template>
<v-list-group :value="node.type === 'root' ? '(root)' : node.fullName">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="node.type === 'root' ? 'mdi-file-tree' : 'mdi-folder'"
:title="node.type === 'root' ? $t('torrentDetail.content.rootNode') : node.name"
:value="node.type === 'root' ? '(root)' : node.fullName">
<template v-slot:append="{ isActive }">
<span class="mr-2">{{ getNodeDescription(node) }}</span>
<v-btn v-if="node.type === 'folder'" color="accent" size="x-small" icon="mdi-pencil" @click.stop="$emit('renameFolder', node)" />
<v-icon :icon="isActive ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</template>
</v-list-item>
</template>
<template v-for="child in node.children">
<FolderNode
v-if="child.type === 'folder'"
:node="child as TreeFolder"
@renameFolder="n => $emit('renameFolder', n)"
@renameFile="n => $emit('renameFile', n)"
@setFilePriority="(n, prio) => $emit('setFilePriority', n, prio)" />
<FileNode
v-if="child.type === 'file'"
:node="child as TreeFile"
@renameFile="n => $emit('renameFile', n)"
@setFilePriority="(n, prio) => $emit('setFilePriority', n, prio)" />
</template>
</v-list-group>
</template>
<style scoped></style>

View file

@ -1,26 +0,0 @@
<script setup lang="ts">
import { FilePriority } from '@/constants/qbit'
import FolderNode from './FolderNode.vue'
import { TreeFile, TreeFolder, TreeRoot } from '@/types/vuetorrent'
defineProps<{
root: TreeRoot
}>()
defineEmits<{
renameFolder: [node: TreeFolder]
renameFile: [node: TreeFile]
setFilePriority: [node: TreeFile, priority: FilePriority]
}>()
</script>
<template>
<v-list density="compact" select-strategy="classic">
<FolderNode
:node="root"
@renameFolder="n => $emit('renameFolder', n)"
@renameFile="n => $emit('renameFile', n)"
@setFilePriority="(n, prio) => $emit('setFilePriority', n, prio)" />
</v-list>
</template>
<style scoped></style>

View file

@ -0,0 +1,3 @@
import Content from './Content.vue'
export default Content

View file

@ -1,15 +1,9 @@
import { TorrentFile } from '@/types/qbit/models'
import { TreeFile, TreeFolder, TreeRoot } from '@/types/vuetorrent'
import { TreeFile, TreeFolder } from '@/types/vuetorrent'
import { MaybeRefOrGetter, ref, toValue, watchEffect } from 'vue'
function getEmptyRoot(): TreeRoot {
return {
type: 'root',
name: '',
fullName: '',
id: '',
children: []
}
function getEmptyRoot() {
return new TreeFolder('(root)', '(root)')
}
export function useTreeBuilder(items: MaybeRefOrGetter<TorrentFile[]>) {
@ -21,7 +15,7 @@ export function useTreeBuilder(items: MaybeRefOrGetter<TorrentFile[]>) {
const files = toValue(items) ?? []
for (const file of files) {
let cursor: TreeRoot | TreeFolder = rootNode
let cursor = rootNode
file.name
.replace('\\', '/')
.split('/')
@ -29,32 +23,13 @@ export function useTreeBuilder(items: MaybeRefOrGetter<TorrentFile[]>) {
const nextPath = parentPath === '' ? nodeName : parentPath + '/' + nodeName
if (file.name.replace('\\', '/').split('/').pop() === nodeName) {
const newFile: TreeFile = {
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)
cursor.children.push(new TreeFile(file, nodeName))
} else {
const folder = cursor.children.find(el => el.name === nodeName) as TreeFolder | undefined
if (folder) {
cursor = folder
} else {
// if not found, create folder and set cursor to folder
const newFolder: TreeFolder = {
type: 'folder',
name: nodeName,
fullName: nextPath,
id: nextPath,
children: []
}
const newFolder = new TreeFolder(nodeName, nextPath)
cursor.children.push(newFolder)
cursor = newFolder
}

View file

@ -1,5 +1,5 @@
export enum FilePriority {
DISABLED = -1,
MIXED = -1,
DO_NOT_DOWNLOAD = 0,
NORMAL = 1,
HIGH = 6,

View file

@ -6,8 +6,8 @@ enum FileIcon {
MUSIC = 'mdi-music',
VIDEO = 'mdi-movie',
SUBTITLE = 'mdi-subtitles',
ARCHIVE = 'mdi-folder-zip',
EXECUTABLE = 'mdi-application'
ARCHIVE = 'mdi-zip-box-outline',
EXECUTABLE = 'mdi-application-brackets'
}
export const typesMap: Record<string, FileIcon> = {
@ -42,12 +42,12 @@ export const typesMap: Record<string, FileIcon> = {
zip: FileIcon.ARCHIVE,
gz: FileIcon.ARCHIVE,
'7z': FileIcon.ARCHIVE,
iso: FileIcon.ARCHIVE,
exe: FileIcon.EXECUTABLE,
msi: FileIcon.EXECUTABLE,
dmg: FileIcon.EXECUTABLE,
deb: FileIcon.EXECUTABLE,
iso: FileIcon.EXECUTABLE,
jar: FileIcon.EXECUTABLE
}

View file

@ -1,4 +1,4 @@
import { formatEta } from '@/helpers/datetime'
import { formatEta } from './datetime'
import { expect, test } from 'vitest'
test('helpers/datetime/formatEta', () => {

View file

@ -1,4 +1,4 @@
import { formatPercent, toPrecision } from '@/helpers/number'
import { formatPercent, toPrecision } from './number'
import { expect, test } from 'vitest'
test('helpers/number/toPrecision', () => {

View file

@ -1,4 +1,4 @@
import { formatSpeed, formatSpeedUnit, formatSpeedValue } from '@/helpers/speed'
import { formatSpeed, formatSpeedUnit, formatSpeedValue } from './speed'
import { expect, test } from 'vitest'
test('helpers/speed/formatSpeedValue', () => {

View file

@ -64,7 +64,8 @@
"high": "High",
"max": "Max",
"normal": "Normal",
"unwanted": "Unwanted"
"unwanted": "Unwanted",
"mixed": "Mixed"
},
"maxRatioAction": {
"pauseTorrent": "Pause torrent",
@ -1060,7 +1061,8 @@
"priority": "Set priority",
"rename": {
"file": "Rename",
"folder": "Rename folder"
"folder": "Rename folder",
"bulk": "Bulk rename"
},
"rootNode": "(Root)"
},

View file

@ -1,15 +1,21 @@
<script lang="ts" setup>
import RightClickMenu from '@/components/Dashboard/TRC/RightClickMenu.vue'
import Toolbar from '@/components/Dashboard/Toolbar.vue'
import TRC from '@/components/Dashboard/RightClick.vue'
import GridView from '@/components/Dashboard/Views/Grid/GridView.vue'
import ListView from '@/components/Dashboard/Views/List/ListView.vue'
import TableView from '@/components/Dashboard/Views/Table/TableView.vue'
import ConfirmDeleteDialog from '@/components/Dialogs/ConfirmDeleteDialog.vue'
import Toolbar from '@/components/Dashboard/Toolbar.vue'
import { useArrayPagination } from '@/composables'
import { DashboardDisplayMode } from '@/constants/vuetorrent'
import { doesCommand } from '@/helpers'
import { useDashboardStore, useDialogStore, useMaindataStore, useTorrentStore, useVueTorrentStore } from '@/stores'
import { Torrent as TorrentType } from '@/types/vuetorrent'
import {
useDashboardStore,
useDialogStore,
useMaindataStore,
useTorrentStore,
useVueTorrentStore
} from '@/stores'
import { RightClickProperties, Torrent as TorrentType } from '@/types/vuetorrent'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@ -29,11 +35,14 @@ const isListView = computed(() => displayMode.value === DashboardDisplayMode.LIS
const isGridView = computed(() => displayMode.value === DashboardDisplayMode.GRID)
const isTableView = computed(() => displayMode.value === DashboardDisplayMode.TABLE)
const { paginatedResults: paginatedTorrents, currentPage, pageCount } = useArrayPagination(filteredTorrents, vuetorrentStore.paginationSize, dashboardPage)
const {
paginatedResults: paginatedTorrents,
currentPage,
pageCount
} = useArrayPagination(filteredTorrents, vuetorrentStore.paginationSize, dashboardPage)
const isAllTorrentsSelected = computed(() => filteredTorrents.value.length <= selectedTorrents.value.length)
const trcProperties = reactive({
const rightClickProperties = reactive<RightClickProperties>({
isVisible: false,
offset: [0, 0]
})
@ -83,13 +92,13 @@ function onTorrentClick(e: { shiftKey: boolean; metaKey: boolean; ctrlKey: boole
}
async function onTorrentRightClick(e: MouseEvent | Touch, torrent: TorrentType) {
if (trcProperties.isVisible) {
trcProperties.isVisible = false
if (rightClickProperties.isVisible) {
rightClickProperties.isVisible = false
await nextTick()
}
trcProperties.isVisible = true
trcProperties.offset = [e.pageX, e.pageY]
rightClickProperties.isVisible = true
rightClickProperties.offset = [e.pageX, e.pageY]
if (!isSelectionMultiple.value) {
dashboardStore.unselectAllTorrents()
@ -172,7 +181,7 @@ function handleKeyboardShortcuts(e: KeyboardEvent) {
}
watch(
() => trcProperties.isVisible,
() => rightClickProperties.isVisible,
newValue => {
if (!newValue && !isSelectionMultiple.value) {
dashboardStore.unselectAllTorrents()
@ -224,7 +233,8 @@ onBeforeUnmount(() => {
</div>
<div v-if="vuetorrentStore.isPaginationOnTop && !vuetorrentStore.isInfiniteScrollActive && pageCount > 1">
<v-pagination v-model="currentPage" :length="pageCount" next-icon="mdi-menu-right" prev-icon="mdi-menu-left" @input="scrollToTop" />
<v-pagination v-model="currentPage" :length="pageCount" next-icon="mdi-menu-right" prev-icon="mdi-menu-left"
@input="scrollToTop" />
</div>
<ListView
@ -257,13 +267,12 @@ onBeforeUnmount(() => {
@endPress="endPress" />
<div v-if="!vuetorrentStore.isPaginationOnTop && !vuetorrentStore.isInfiniteScrollActive && pageCount > 1">
<v-pagination v-model="currentPage" :length="pageCount" next-icon="mdi-menu-right" prev-icon="mdi-menu-left" @input="scrollToTop" />
<v-pagination v-model="currentPage" :length="pageCount" next-icon="mdi-menu-right" prev-icon="mdi-menu-left"
@input="scrollToTop" />
</div>
</div>
<div :style="`position: absolute; left: ${trcProperties.offset[0]}px; top: ${trcProperties.offset[1]}px;`">
<RightClickMenu v-model="trcProperties.isVisible" />
</div>
<TRC :right-click-properties="rightClickProperties" />
</template>
<style>

View file

@ -1,25 +1,35 @@
<script setup lang="ts">
import RightClickMenu from '@/components/Core/RightClickMenu'
import ConfirmDeleteDialog from '@/components/Dialogs/ConfirmDeleteDialog.vue'
import Content from '@/components/TorrentDetail/Content.vue'
import Content from '@/components/TorrentDetail/Content'
import Info from '@/components/TorrentDetail/Info.vue'
import Overview from '@/components/TorrentDetail/Overview.vue'
import Peers from '@/components/TorrentDetail/Peers.vue'
import TagsAndCategories from '@/components/TorrentDetail/TagsAndCategories.vue'
import Trackers from '@/components/TorrentDetail/Trackers.vue'
import { useDialogStore, useTorrentStore } from '@/stores'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useContentStore, useDialogStore, useTorrentStore } from '@/stores'
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const contentStore = useContentStore()
const dialogStore = useDialogStore()
const torrentStore = useTorrentStore()
const tabs = [
{ text: t('torrentDetail.tabs.overview'), value: 'overview' },
{ text: t('torrentDetail.tabs.info'), value: 'info' },
{ text: t('torrentDetail.tabs.trackers'), value: 'trackers' },
{ text: t('torrentDetail.tabs.peers'), value: 'peers' },
{ text: t('torrentDetail.tabs.content'), value: 'content' },
{ text: t('torrentDetail.tabs.tagsAndCategories'), value: 'tagsAndCategories' }
]
const tab = ref('overview')
const hash = computed(() => route.params.hash as string)
const hash = computed(() => router.currentRoute.value.params.hash as string)
const torrent = computed(() => torrentStore.getTorrentByHash(hash.value))
const goHome = () => {
@ -42,8 +52,20 @@ function handleKeyboardShortcut(e: KeyboardEvent) {
}
}
function updateTabHandle() {
const tabRouteParam = router.currentRoute.value.params.tab as string
if (tabRouteParam) {
tab.value = tabRouteParam
}
}
watchEffect(() => {
updateTabHandle()
})
onMounted(() => {
document.addEventListener('keydown', handleKeyboardShortcut)
updateTabHandle()
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeyboardShortcut)
@ -67,12 +89,7 @@ onBeforeUnmount(() => {
<v-row class="ma-0 pa-0">
<v-tabs v-model="tab" bg-color="primary" grow show-arrows>
<v-tab value="overview">{{ t('torrentDetail.tabs.overview') }}</v-tab>
<v-tab value="info">{{ t('torrentDetail.tabs.info') }}</v-tab>
<v-tab value="trackers">{{ t('torrentDetail.tabs.trackers') }}</v-tab>
<v-tab value="peers">{{ t('torrentDetail.tabs.peers') }}</v-tab>
<v-tab value="content">{{ t('torrentDetail.tabs.content') }}</v-tab>
<v-tab value="tagsAndCategories">{{ t('torrentDetail.tabs.tagsAndCategories') }}</v-tab>
<v-tab v-for="{ text, value } in tabs" :value="value" :href="`#/torrent/${hash}/${value}`" :text="text" />
</v-tabs>
</v-row>
@ -97,6 +114,10 @@ onBeforeUnmount(() => {
</v-window-item>
</v-window>
</div>
<div :style="`position: absolute; left: ${contentStore.rightClickProperties.offset[0]}px; top: ${contentStore.rightClickProperties.offset[1]}px;`">
<RightClickMenu v-model="contentStore.rightClickProperties.isVisible" :menu-data="contentStore.menuData" />
</div>
</template>
<style scoped></style>

View file

@ -28,7 +28,7 @@ export const routes: RouteRecordRaw[] = [
},
{
name: 'torrentDetail',
path: '/torrent/:hash',
path: '/torrent/:hash/:tab?',
component: () => import('./TorrentDetail.vue')
},
{

162
src/stores/content.ts Normal file
View file

@ -0,0 +1,162 @@
import { useTreeBuilder } from '@/composables'
import { FilePriority } from '@/constants/qbit'
import { qbit } from '@/services'
import { useDialogStore } from '@/stores/dialog'
import { useMaindataStore } from '@/stores/maindata'
import { useVueTorrentStore } from '@/stores/vuetorrent'
import { TorrentFile } from '@/types/qbit/models'
import { RightClickMenuEntryType, RightClickProperties, TreeNode } from '@/types/vuetorrent'
import { useIntervalFn } from '@vueuse/core'
import { defineStore, storeToRefs } from 'pinia'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
export const useContentStore = defineStore('torrentDetail', () => {
const { t } = useI18n()
const route = useRoute()
const dialogStore = useDialogStore()
const maindataStore = useMaindataStore()
const { fileContentInterval } = storeToRefs(useVueTorrentStore())
const hash = computed(() => route.params.hash as string)
const rightClickProperties = reactive<RightClickProperties>({
isVisible: false,
offset: [0, 0]
})
const _lock = ref(false)
const cachedFiles = ref<TorrentFile[]>([])
const openedItems = ref(['(root)'])
const { tree } = useTreeBuilder(cachedFiles)
const flatTree = computed(() => {
const flatten = (node: TreeNode, parentPath: string): TreeNode[] => {
const path = parentPath === '' ? node.name : parentPath + '/' + node.name
if (node.type === 'folder' && openedItems.value.includes(node.fullName)) {
const children = node.children
.toSorted((a: TreeNode, b: TreeNode) => {
if (a.type === 'folder' && b.type === 'file') return -1
if (a.type === 'file' && b.type === 'folder') return 1
return a.name.localeCompare(b.name)
})
.flatMap(el => flatten(el, path))
return [node, ...children]
} else {
return [node]
}
}
return flatten(tree.value, '')
})
const internalSelection = ref<Set<string>>(new Set())
const selectedNodes = computed<TreeNode[]>(() => internalSelection.value.size === 0 ? [] : flatTree.value.filter(node => internalSelection.value.has(node.fullName)))
const selectedNode = computed<TreeNode | null>(() => selectedNodes.value.length > 0 ? selectedNodes.value[0] : null)
const selectedIds = computed<number[]>(() => selectedNodes.value.map(node => node.getChildrenIds()).flat().filter((v, i, a) => a.indexOf(v) === i))
const menuData = computed<RightClickMenuEntryType[]>(() => ([
{
text: t(`torrentDetail.content.rename.bulk`),
icon: 'mdi-rename',
hidden: true, // internalSelection.value.size <= 1
action: bulkRename
},
{
text: t(`torrentDetail.content.rename.${ selectedNode.value?.type || 'file' }`),
icon: 'mdi-rename',
hidden: internalSelection.value.size > 1 || selectedNode.value?.name === '(root)',
action: () => renameNode(selectedNode.value!)
},
{
text: t('torrentDetail.content.priority'),
icon: 'mdi-trending-up',
children: [
{ text: t('constants.file_priority.max'), icon: 'mdi-arrow-up', action: () => setFilePriority(selectedIds.value, FilePriority.MAXIMAL) },
{ text: t('constants.file_priority.high'), icon: 'mdi-arrow-top-right', action: () => setFilePriority(selectedIds.value, FilePriority.HIGH) },
{ text: t('constants.file_priority.normal'), icon: 'mdi-minus', action: () => setFilePriority(selectedIds.value, FilePriority.NORMAL) },
{ text: t('constants.file_priority.unwanted'), icon: 'mdi-cancel', action: () => setFilePriority(selectedIds.value, FilePriority.DO_NOT_DOWNLOAD) }
]
}
]))
const { pause: pauseTimer, resume: resumeTimer } = useIntervalFn(updateFileTree, fileContentInterval, {
immediate: false,
immediateCallback: true
})
async function updateFileTree() {
if (_lock.value) return
_lock.value = true
await nextTick()
cachedFiles.value = await maindataStore.fetchFiles(hash.value)
_lock.value = false
await nextTick()
}
const renameDialog = ref('')
const renamePayload = reactive({
hash: '',
isFolder: false,
oldName: ''
})
async function renameNode(node: TreeNode) {
const { default: MoveTorrentFileDialog } = await import('@/components/Dialogs/MoveTorrentFileDialog.vue')
renamePayload.hash = hash.value
renamePayload.isFolder = node.type === 'folder'
renamePayload.oldName = node.fullName
renameDialog.value = dialogStore.createDialog(MoveTorrentFileDialog, renamePayload)
}
async function bulkRename() {
//TODO
}
async function renameTorrentFile(hash: string, oldPath: string, newPath: string) {
await qbit.renameFile(hash, oldPath, newPath)
}
async function renameTorrentFolder(hash: string, oldPath: string, newPath: string) {
await qbit.renameFolder(hash, oldPath, newPath)
}
async function setFilePriority(fileIdx: number[], priority: FilePriority) {
await qbit.setTorrentFilePriority(hash.value, fileIdx, priority)
await updateFileTree()
}
watch(
() => dialogStore.isDialogOpened(renameDialog.value),
async v => {
if (!v) {
await updateFileTree()
}
}
)
return {
rightClickProperties,
internalSelection,
menuData,
openedItems,
tree,
flatTree,
updateFileTree,
pauseTimer,
resumeTimer,
renameTorrentFile,
renameTorrentFolder,
setFilePriority,
$reset: () => {
while (_lock.value) {}
internalSelection.value = new Set()
cachedFiles.value = []
openedItems.value = ['(root)']
}
}
})

View file

@ -1,6 +1,7 @@
import { useAddTorrentStore } from './addTorrents'
import { useAppStore } from './app'
import { useAuthStore } from './auth'
import { useContentStore } from './content'
import { useDashboardStore } from './dashboard'
import { useDialogStore } from './dialog'
import { useHistoryStore } from './history'
@ -17,6 +18,7 @@ export {
useAddTorrentStore,
useAppStore,
useAuthStore,
useContentStore,
useDashboardStore,
useDialogStore,
useHistoryStore,

View file

@ -1,5 +1,5 @@
import { useTorrentBuilder } from '@/composables'
import { FilePriority, SortOptions } from '@/constants/qbit'
import { SortOptions } from '@/constants/qbit'
import { extractHostname } from '@/helpers'
import { qbit } from '@/services'
import { Category, ServerState } from '@/types/qbit/models'
@ -119,10 +119,10 @@ export const useMaindataStore = defineStore('maindata', () => {
if (vueTorrentStore.showTrackerFilter) {
trackers.value = data
.map(t => t.tracker)
.map(url => extractHostname(url))
.filter((domain, index, self) => index === self.indexOf(domain) && domain)
.sort()
.map(t => t.tracker)
.map(url => extractHostname(url))
.filter((domain, index, self) => index === self.indexOf(domain) && domain)
.sort()
}
// update torrents
@ -148,14 +148,6 @@ export const useMaindataStore = defineStore('maindata', () => {
return await qbit.getTorrentFiles(hash, indexes)
}
async function renameTorrentFile(hash: string, oldPath: string, newPath: string) {
await qbit.renameFile(hash, oldPath, newPath)
}
async function renameTorrentFolder(hash: string, oldPath: string, newPath: string) {
await qbit.renameFolder(hash, oldPath, newPath)
}
async function fetchPieceState(hash: string) {
return await qbit.getTorrentPieceStates(hash)
}
@ -208,10 +200,6 @@ export const useMaindataStore = defineStore('maindata', () => {
await qbit.banPeers(peers)
}
async function setTorrentFilePriority(hash: string, ids: number[], priority: FilePriority) {
await qbit.setTorrentFilePriority(hash, ids, priority)
}
async function setDownloadLimit(limit: number, hashes: string[]) {
return await qbit.setDownloadLimit(hashes, limit)
}
@ -242,8 +230,6 @@ export const useMaindataStore = defineStore('maindata', () => {
deleteTags,
updateMaindata,
fetchFiles,
renameTorrentFile,
renameTorrentFolder,
fetchPieceState,
reannounceTorrents,
toggleSeqDl,
@ -257,7 +243,6 @@ export const useMaindataStore = defineStore('maindata', () => {
getTorrentPeers,
addTorrentPeers,
banPeers,
setTorrentFilePriority,
setDownloadLimit,
setUploadLimit,
setShareLimit,

View file

@ -1,4 +1,4 @@
export type TRCMenuEntry = {
export type RightClickMenuEntryType = {
text: string
icon?: string
action?: () => void
@ -6,6 +6,5 @@ export type TRCMenuEntry = {
disabled?: boolean
disabledText?: string
disabledIcon?: string
divider?: boolean
children?: TRCMenuEntry[]
children?: RightClickMenuEntryType[]
}

View file

@ -0,0 +1,4 @@
export default interface RightClickProperties {
isVisible: boolean,
offset: [number, number]
}

View file

@ -0,0 +1,108 @@
import { FilePriority } from '@/constants/qbit'
import { beforeEach, expect, test } from 'vitest'
import { TreeFile, TreeFolder } from './TreeObjects'
const file_unwanted = new TreeFile({
availability: 1,
index: 0,
is_seed: true,
name: 'test/test1.txt',
piece_range: [0, 0],
priority: FilePriority.DO_NOT_DOWNLOAD,
progress: 0,
size: 1000,
}, 'test1.txt')
const file_normal = new TreeFile({
availability: 1,
index: 1,
is_seed: true,
name: 'test/test2.txt',
piece_range: [0, 0],
priority: FilePriority.NORMAL,
progress: 1,
size: 1000,
}, 'test2.txt')
const file_high = new TreeFile({
availability: 1,
index: 2,
is_seed: true,
name: 'test/test3.txt',
piece_range: [0, 0],
priority: FilePriority.HIGH,
progress: 1,
size: 1000,
}, 'test3.txt')
const file_maximal = new TreeFile({
availability: 1,
index: 3,
is_seed: true,
name: 'test/test4.txt',
piece_range: [0, 0],
priority: FilePriority.MAXIMAL,
progress: 1,
size: 1000,
}, 'test4.txt')
const folder = new TreeFolder('test', 'test')
beforeEach(() => {
folder.children = []
})
test('getPriority', () => {
expect(folder.getPriority()).toBe(FilePriority.DO_NOT_DOWNLOAD)
folder.children = [file_unwanted, file_normal, file_high, file_maximal]
expect(folder.getPriority()).toBe(FilePriority.MIXED)
})
test('getChildrenIds', () => {
folder.children = [file_unwanted, file_normal, file_high, file_maximal]
expect(folder.getChildrenIds()).toEqual([0, 1, 2, 3])
})
test('isSelected', () => {
const selection = new Set<string>(['test', 'test/test1.txt'])
expect(folder.isSelected(selection)).toBe(true)
expect(file_unwanted.isSelected(selection)).toBe(true)
expect(file_normal.isSelected(selection)).toBe(false)
expect(file_high.isSelected(selection)).toBe(false)
expect(file_maximal.isSelected(selection)).toBe(false)
})
test('isWanted', () => {
folder.children = [file_unwanted, file_normal, file_high, file_maximal]
expect(folder.isWanted()).toBe(null)
folder.children = [file_normal, file_high, file_maximal]
expect(folder.isWanted()).toBe(true)
folder.children = [file_unwanted]
expect(folder.isWanted()).toBe(false)
})
test('getProgress', () => {
expect(folder.getProgress()).toBe(0)
folder.children = [file_unwanted, file_normal, file_high, file_maximal]
expect(folder.getProgress()).toBe(1)
})
test('getDeepCount', () => {
expect(file_unwanted.getDeepCount()).toEqual([0, 1])
expect(folder.getDeepCount()).toEqual([1, 0])
folder.children = [file_unwanted, file_normal, file_high, file_maximal]
expect(folder.getDeepCount()).toEqual([1, 4])
})
test('getSize', () => {
expect(folder.getSize()).toBe(0)
folder.children = [file_unwanted, file_normal, file_high, file_maximal]
expect(folder.getSize()).toBe(4000)
})

View file

@ -1,26 +1,19 @@
import { FilePriority } from '@/constants/qbit'
import { TorrentFile } from '@/types/qbit/models'
export interface TreeNode {
export type TreeNode = TreeFile | TreeFolder
export class TreeFile {
/** 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
/** 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 {
type: 'file'
/** File index */
id: number
/** file pieces currently available (percentage/100) */
/** File name with extension */
name: string
/** File full name (relative path + name) */
fullName: string
/** File pieces currently available (percentage/100) */
availability: number
/** File index */
index: number
/** True if file is seeding/complete */
is_seed: boolean
/** File priority */
@ -29,18 +22,113 @@ export interface TreeFile extends TreeNode {
progress: number
/** File size in bytes */
size: number
constructor(file: TorrentFile, filename: string) {
this.type = 'file'
this.name = filename
this.id = file.index
this.fullName = file.name
this.availability = file.availability
this.is_seed = file.is_seed
this.priority = file.priority
this.progress = file.progress
this.size = file.size
}
getPriority(): FilePriority {
return this.priority
}
getChildrenIds(): number[] {
return [this.id]
}
isSelected(selection: Set<string>): boolean {
return selection.has(this.fullName)
}
isWanted(): boolean {
return this.priority !== FilePriority.DO_NOT_DOWNLOAD
}
getProgress(): number {
return this.progress
}
getDeepCount(): [number, number] {
return [0, 1]
}
getSize(): number {
return this.size
}
}
export interface TreeFolder extends TreeNode {
export class TreeFolder {
/** Represents the type of the node */
type: 'folder'
/** Node full name */
id: string
fullName: string
name: string
children: TreeNode[]
}
export interface TreeRoot extends TreeNode {
type: 'root'
name: ''
fullName: ''
id: ''
children: TreeNode[]
constructor(name: string, fullName: string) {
this.type = 'folder'
this.id = fullName
this.fullName = fullName
this.name = name
this.children = []
}
getPriority(): FilePriority {
if (this.children.length === 0) return FilePriority.DO_NOT_DOWNLOAD
return this.children.map(child => child.getPriority()).reduce((prev, curr) => {
if (prev === FilePriority.MIXED || prev === curr) return prev
return FilePriority.MIXED
})
}
getChildrenIds(): number[] {
return this.children.map(child => child.getChildrenIds()).flat()
}
isSelected(selection: Set<string>): boolean {
return selection.has(this.fullName)
}
isWanted(): boolean | null {
const children = this.children.map(child => child.isWanted())
const indeterminate = children.filter(child => child === null).length
const wanted = children.filter(child => child === true).length
const unwanted = children.filter(child => child === false).length
if (indeterminate > 0) return null
if (wanted > 0 && unwanted > 0) return null
if (wanted > 0) return true
if (unwanted > 0) return false
return null
}
getProgress(): number {
const values = this.children
.filter(child => child.getPriority() !== FilePriority.DO_NOT_DOWNLOAD)
.map(child => child.getProgress())
if (values.length === 0) return 0
return values.reduce((prev, curr) => prev + curr, 0) / values.length
}
getDeepCount(): [number, number] {
const [folders, files] = this.children.map(child => child.getDeepCount()).reduce((prev, curr) => {
return [prev[0] + curr[0], prev[1] + curr[1]]
}, [0, 0])
return [folders + 1, files]
}
getSize(): number {
return this.children.map(child => child.getSize()).reduce((prev, curr) => prev + curr, 0)
}
}

View file

@ -1,7 +1,9 @@
import type { RssArticle } from './RssArticle'
import type { SearchData } from './SearchData'
import type Torrent from './Torrent'
import type { TreeNode, TreeFile, TreeFolder, TreeRoot } from './TreeObjects'
import type { TRCMenuEntry } from './TRCMenuEntry'
import type { TreeNode } from './TreeObjects'
import { TreeFile, TreeFolder } from './TreeObjects'
import type { RightClickMenuEntryType } from './RightClickMenuEntryType'
import type RightClickProperties from './RightClickProperties'
export { RssArticle, SearchData, Torrent, TreeNode, TreeFile, TreeFolder, TreeRoot, TRCMenuEntry }
export { RssArticle, SearchData, Torrent, TreeNode, TreeFile, TreeFolder, RightClickMenuEntryType, RightClickProperties }

View file

@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "ES2023", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */