mirror of
https://github.com/VueTorrent/VueTorrent.git
synced 2025-02-25 11:51:15 +03:00
feat(content): Rework content tab entirely (#1470)
This commit is contained in:
parent
196aff6d60
commit
6758072296
35 changed files with 828 additions and 486 deletions
2
.github/workflows/build-release.yml
vendored
2
.github/workflows/build-release.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
|
@ -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
|
||||
|
|
23
src/components/Core/RightClickMenu/RightClickMenu.vue
Normal file
23
src/components/Core/RightClickMenu/RightClickMenu.vue
Normal 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>
|
|
@ -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>
|
3
src/components/Core/RightClickMenu/index.ts
Normal file
3
src/components/Core/RightClickMenu/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import RightClickMenu from './RightClickMenu.vue'
|
||||
|
||||
export default RightClickMenu
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
88
src/components/TorrentDetail/Content/Content.vue
Normal file
88
src/components/TorrentDetail/Content/Content.vue
Normal 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>
|
163
src/components/TorrentDetail/Content/ContentNode.vue
Normal file
163
src/components/TorrentDetail/Content/ContentNode.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
3
src/components/TorrentDetail/Content/index.ts
Normal file
3
src/components/TorrentDetail/Content/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Content from './Content.vue'
|
||||
|
||||
export default Content
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export enum FilePriority {
|
||||
DISABLED = -1,
|
||||
MIXED = -1,
|
||||
DO_NOT_DOWNLOAD = 0,
|
||||
NORMAL = 1,
|
||||
HIGH = 6,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { formatEta } from '@/helpers/datetime'
|
||||
import { formatEta } from './datetime'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('helpers/datetime/formatEta', () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { formatPercent, toPrecision } from '@/helpers/number'
|
||||
import { formatPercent, toPrecision } from './number'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('helpers/number/toPrecision', () => {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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)"
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
162
src/stores/content.ts
Normal 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)']
|
||||
}
|
||||
}
|
||||
})
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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[]
|
||||
}
|
4
src/types/vuetorrent/RightClickProperties.ts
Normal file
4
src/types/vuetorrent/RightClickProperties.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default interface RightClickProperties {
|
||||
isVisible: boolean,
|
||||
offset: [number, number]
|
||||
}
|
108
src/types/vuetorrent/TreeObjects.spec.ts
Normal file
108
src/types/vuetorrent/TreeObjects.spec.ts
Normal 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)
|
||||
})
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 */
|
||||
|
|
Loading…
Add table
Reference in a new issue