Added autocomplete for cli options
This commit is contained in:
parent
f31ea55ca0
commit
a0533a0861
19 changed files with 724 additions and 387 deletions
|
|
@ -358,9 +358,11 @@ div.is-centered {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.modal-content-max {
|
||||
width: max-content;
|
||||
width: calc(100% - 20px);
|
||||
max-width: 1344px;
|
||||
min-width: 320px;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,22 +90,20 @@
|
|||
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="cli_options">
|
||||
<label class="label is-unselectable" for="cli_options">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>
|
||||
Command options for yt-dlp -
|
||||
<NuxtLink @click="showOptions = true" v-text="'View Options'" />
|
||||
</span>
|
||||
<span>Command options for yt-dlp</span>
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options" :disabled="addInProgress"
|
||||
placeholder="command options to use, e.g. --proxy 1.2.3.4:3128" />
|
||||
</div>
|
||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||
:disabled="addInProgress" />
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Not all options are supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
||||
ignored</NuxtLink>. Use with caution.</span>
|
||||
<span>
|
||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
||||
supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||
are ignored</NuxtLink>. Use with caution.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -211,6 +209,8 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
|
||||
|
||||
const emitter = defineEmits<{
|
||||
|
|
@ -227,6 +227,7 @@ const props = defineProps<{
|
|||
const toast = useNotification()
|
||||
const showImport = useStorage('showImport', false)
|
||||
const box = useConfirm()
|
||||
const config = useConfigStore()
|
||||
|
||||
const form = reactive<ConditionItem>(JSON.parse(JSON.stringify(props.item)))
|
||||
const import_string = ref('')
|
||||
|
|
@ -238,6 +239,15 @@ const test_data = ref<{
|
|||
data: { status: boolean | null, data: Record<string, any> }
|
||||
}>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } })
|
||||
const showOptions = ref<boolean>(false)
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(opt => opt.flags
|
||||
.filter(flag => flag.startsWith('--'))
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(() => form.filter, () => test_data.value.changed = true)
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,8 @@
|
|||
<span class="icon"><i class="fas fa-terminal" /></span>
|
||||
<span>Associated yt-dlp option</span>
|
||||
</label>
|
||||
<input type="text" v-model="item.field" class="input" :disabled="isLoading" />
|
||||
<InputAutocomplete v-model="item.field" :options="ytDlpOptions" :disabled="isLoading"
|
||||
placeholder="Type or select a yt-dlp option" />
|
||||
<span class="help is-bold">
|
||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
|
||||
</span>
|
||||
|
|
@ -164,15 +165,20 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, ref } from 'vue'
|
||||
import type { DLField } from '~/types/dl_fields'
|
||||
import InputAutocomplete from '~/components/InputAutocomplete.vue'
|
||||
import { disableOpacity, enableOpacity } from '~/utils'
|
||||
|
||||
import type { DLField } from '~/types/dl_fields'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
|
||||
const emitter = defineEmits<{ (e: 'cancel'): void }>()
|
||||
|
||||
const toast = useNotification()
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const items = ref<DLField[]>([])
|
||||
const config = useConfigStore()
|
||||
const ytDlpOptions = ref<AutoCompleteOptions>([])
|
||||
|
||||
const FieldTypes = {
|
||||
STRING: 'string',
|
||||
|
|
@ -274,6 +280,12 @@ const validateItem = (item: DLField, index: number): boolean => {
|
|||
|
||||
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
|
||||
.filter(opt => !opt.ignored).flatMap(opt => opt.flags
|
||||
.filter(flag => flag.startsWith('--'))
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||
{ immediate: true }
|
||||
)
|
||||
onMounted(async () => {
|
||||
disableOpacity()
|
||||
await loadContent()
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
code {
|
||||
color: var(--bulma-code) !important
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="modal is-active" v-if="false === externalModel">
|
||||
<div class="modal-background" @click="emitter('closeModel')"></div>
|
||||
<div class="modal-content" style="width:60vw;">
|
||||
<div class="modal-content modal-content-max">
|
||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
|
|
@ -25,7 +26,7 @@ code {
|
|||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" @click="emitter('closeModel')"></button>
|
||||
</div>
|
||||
<div style="width:70vw; height: 80vh;" v-else>
|
||||
<div class="modal-content-max" style="height: 80vh;" v-else>
|
||||
<div class="content p-0 m-0" style="position: relative">
|
||||
<pre><code class="p-4 is-block" v-text="data" /></pre>
|
||||
<button class="button m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
|
||||
|
|
|
|||
133
ui/app/components/InputAutocomplete.vue
Normal file
133
ui/app/components/InputAutocomplete.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
||||
<div class="control" style="width:100%;">
|
||||
<input v-model="model" @focus="onFocus" @blur="hideList" @keydown="handleKeydown" @input="onInput" class="input"
|
||||
:placeholder="placeholder" autocomplete="new-password" style="width:100%; position:relative; z-index:2;"
|
||||
:disabled="disabled" />
|
||||
</div>
|
||||
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
||||
<div class="dropdown-content" style="width:100%; max-height:10em; overflow-y:auto;">
|
||||
<a v-for="(option, idx) in filteredOptions" :key="option.value" @mousedown.prevent="selectOption(option.value)"
|
||||
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
||||
style="display:flex; justify-content:space-between;" :ref="el => setDropdownItemRef(el, idx)">
|
||||
<span class="has-text-weight-bold">{{ option.value }}</span>
|
||||
<abbr class="has-text-grey-light is-text-overflow" :title="option.description" style="margin-left:1em;">
|
||||
{{ option.description }}
|
||||
</abbr>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, defineModel, defineProps, nextTick } from 'vue'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
|
||||
const props = defineProps<{
|
||||
options: AutoCompleteOptions
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
const onInput = () => {
|
||||
if (false === showList.value) {
|
||||
showList.value = true
|
||||
}
|
||||
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
||||
}
|
||||
|
||||
const showList = ref(false)
|
||||
const highlightedIndex = ref(-1)
|
||||
const dropdownItemRefs = ref<(HTMLElement | null)[]>([])
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!model.value) {
|
||||
return props.options
|
||||
}
|
||||
const val = model.value.toLowerCase()
|
||||
const startsWithFlag = []
|
||||
const includesFlag = []
|
||||
const includesDesc = []
|
||||
for (const opt of props.options) {
|
||||
const flag = opt.value.toLowerCase()
|
||||
const desc = opt.description.toLowerCase()
|
||||
if (flag.startsWith(val)) {
|
||||
startsWithFlag.push(opt)
|
||||
} else if (flag.includes(val)) {
|
||||
includesFlag.push(opt)
|
||||
} else if (desc.includes(val)) {
|
||||
includesDesc.push(opt)
|
||||
}
|
||||
}
|
||||
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
||||
})
|
||||
|
||||
const selectOption = (val: string) => {
|
||||
model.value = val
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
|
||||
const hideList = () => {
|
||||
setTimeout(() => {
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
dropdownItemRefs.value = []
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
showList.value = true
|
||||
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
||||
}
|
||||
|
||||
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
|
||||
dropdownItemRefs.value[idx] = el instanceof HTMLElement ? el : null
|
||||
}
|
||||
|
||||
watch(filteredOptions, () => dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null))
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (!filteredOptions.value.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const pageSize = 5
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1)
|
||||
nextTick(() => scrollHighlightedIntoView())
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
||||
nextTick(() => scrollHighlightedIntoView())
|
||||
} else if (e.key === 'PageDown') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + pageSize, filteredOptions.value.length - 1)
|
||||
nextTick(() => scrollHighlightedIntoView())
|
||||
} else if (e.key === 'PageUp') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0)
|
||||
nextTick(() => scrollHighlightedIntoView())
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
||||
filteredOptions.value[highlightedIndex.value] : undefined
|
||||
if (selected) {
|
||||
e.preventDefault()
|
||||
selectOption(selected.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollHighlightedIntoView() {
|
||||
const el = dropdownItemRefs.value[highlightedIndex.value]
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
el.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
</script>
|
||||
|
|
@ -116,23 +116,22 @@
|
|||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<DLInput id="cli_options" type="text" label="Command options for yt-dlp" v-model="form.cli"
|
||||
icon="fa-solid fa-terminal" :disabled="!socket.isConnected || addInProgress">
|
||||
<template #title>
|
||||
<div class="field">
|
||||
<label class="label is-unselectable" for="cli_options">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>Command options for yt-dlp -
|
||||
<NuxtLink @click="showOptions = true" v-text="'View Options'" />
|
||||
<span>Command options for yt-dlp</span>
|
||||
</label>
|
||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt" />
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are supported
|
||||
<NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
||||
ignored</NuxtLink>. Use with caution.
|
||||
</span>
|
||||
</template>
|
||||
<template #help>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Not all options are supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
||||
ignored</NuxtLink>. Use with caution.</span>
|
||||
</span>
|
||||
</template>
|
||||
</DLInput>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
|
|
@ -241,7 +240,9 @@
|
|||
<script setup lang="ts">
|
||||
import 'assets/css/bulma-switch.css'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||
import type { item_request } from '~/types/item'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
|
||||
const props = defineProps<{ item?: Partial<item_request> }>()
|
||||
const emitter = defineEmits<{
|
||||
|
|
@ -263,6 +264,7 @@ const addInProgress = ref<boolean>(false)
|
|||
const showFields = ref<boolean>(false)
|
||||
const showOptions = ref<boolean>(false)
|
||||
const dlFieldsExtra = ['--no-download-archive']
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||
|
||||
const form = useStorage<item_request>('local_config_v1', {
|
||||
id: null,
|
||||
|
|
@ -445,6 +447,14 @@ onUpdated(async () => {
|
|||
}
|
||||
})
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(opt => opt.flags
|
||||
.filter(flag => flag.startsWith('--'))
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
|
||||
|
|
|
|||
|
|
@ -143,22 +143,22 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="cli_options">
|
||||
<span>Command options for yt-dlp -
|
||||
<NuxtLink @click="showOptions = true" v-text="'View Options'" />
|
||||
</span>
|
||||
<label class="label is-unselectable" for="cli_options">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>Command options for yt-dlp</span>
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options" :disabled="addInProgress"
|
||||
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
|
||||
</div>
|
||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||
:disabled="addInProgress" />
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Not all options are supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
||||
ignored</NuxtLink>. Use with caution.</span>
|
||||
<span>
|
||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
||||
supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||
are ignored</NuxtLink>. Use with caution.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -184,7 +184,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="description">
|
||||
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
||||
|
|
@ -235,6 +235,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
import type { Preset, PresetImport } from '~/types/presets'
|
||||
|
||||
const emitter = defineEmits<{
|
||||
|
|
@ -256,6 +257,15 @@ const import_string = ref<string>('')
|
|||
const showImport = useStorage<boolean>('showImport', false)
|
||||
const selected_preset = ref<string>('')
|
||||
const showOptions = ref<boolean>(false)
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(opt => opt.flags
|
||||
.filter(flag => flag.startsWith('--'))
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const checkInfo = async (): Promise<void> => {
|
||||
for (const key of ['name']) {
|
||||
|
|
|
|||
|
|
@ -232,22 +232,20 @@
|
|||
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="cli_options">
|
||||
<label class="label is-unselectable" for="cli_options">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>Command options for yt-dlp -
|
||||
<NuxtLink @click="showOptions = true" v-text="'View Options'" />
|
||||
</span>
|
||||
<span>Command options for yt-dlp</span>
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea type="text" class="textarea is-pre" v-model="form.cli" id="cli_options"
|
||||
:disabled="addInProgress"
|
||||
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
|
||||
</div>
|
||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||
:disabled="addInProgress" />
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Not all options are supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
||||
ignored</NuxtLink>. Use with caution.</span>
|
||||
<span>
|
||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
||||
supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||
are ignored</NuxtLink>. Use with caution.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -307,6 +305,8 @@
|
|||
import 'assets/css/bulma-switch.css'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { CronExpressionParser } from 'cron-parser'
|
||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
import type { exported_task, task_item } from '~/types/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -328,11 +328,20 @@ const showImport = useStorage('showImport', false)
|
|||
const convertInProgress = ref<boolean>(false)
|
||||
const import_string = ref<string>('')
|
||||
const showOptions = ref<boolean>(false)
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||
|
||||
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
|
||||
|
||||
const form = reactive<task_item>({ ...props.task })
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(opt => opt.flags
|
||||
.filter(flag => flag.startsWith('--'))
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.task?.preset || '' === props.task.preset) {
|
||||
form.preset = toRaw(config.app.default_preset)
|
||||
|
|
|
|||
136
ui/app/components/TextareaAutocomplete.vue
Normal file
136
ui/app/components/TextareaAutocomplete.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<template>
|
||||
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
||||
<div class="control" style="width:100%;">
|
||||
<textarea v-model="localValue" @input="onInput" @focus="showList = true" @blur="hideList" @keydown="onKeydown"
|
||||
class="textarea" :placeholder="placeholder" autocomplete="off" style="width:100%; position:relative; z-index:2;"
|
||||
rows="4" :disabled="disabled" />
|
||||
</div>
|
||||
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
||||
<div class="dropdown-content" style="width:100%; max-height:11em; overflow-y:auto;">
|
||||
<a v-for="(option, idx) in filteredOptions" :key="option.value" @mousedown.prevent="appendFlag(option.value)"
|
||||
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
||||
style="display:flex; justify-content:space-between;" ref="dropdownItems">
|
||||
<span class="has-text-weight-bold">{{ option.value }}</span>
|
||||
<span class="has-text-grey-light is-text-overflow" style="margin-left:1em;">{{ option.description }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, defineModel, defineProps, watch, nextTick } from 'vue'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
|
||||
const props = defineProps<{
|
||||
options: AutoCompleteOptions
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>()
|
||||
const localValue = ref(model.value || '')
|
||||
const showList = ref(false)
|
||||
const highlightedIndex = ref(-1)
|
||||
const dropdownItems = ref<HTMLElement[]>([])
|
||||
|
||||
watch(model, val => localValue.value = val || '')
|
||||
watch(localValue, val => model.value = val)
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const lastWord = localValue.value.split(/\s+/).pop() || ''
|
||||
if (!lastWord) {
|
||||
return props.options
|
||||
}
|
||||
const val = lastWord.toLowerCase()
|
||||
const startsWithFlag = []
|
||||
const includesFlag = []
|
||||
const includesDesc = []
|
||||
for (const opt of props.options) {
|
||||
const flag = opt.value.toLowerCase()
|
||||
const desc = opt.description.toLowerCase()
|
||||
if (flag.startsWith(val)) {
|
||||
startsWithFlag.push(opt)
|
||||
} else if (flag.includes(val)) {
|
||||
includesFlag.push(opt)
|
||||
} else if (desc.includes(val)) {
|
||||
includesDesc.push(opt)
|
||||
}
|
||||
}
|
||||
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
||||
})
|
||||
|
||||
const appendFlag = (flag: string) => {
|
||||
// Only replace the last word if the cursor is at the end and the last word is not followed by a space
|
||||
const value = localValue.value
|
||||
const cursorAtEnd = true // textarea autocomplete only works at end
|
||||
const match = value.match(/(\S+)$/)
|
||||
if (match && cursorAtEnd && !value.endsWith(' ')) {
|
||||
// Replace last word
|
||||
localValue.value = value.slice(0, match.index) + flag
|
||||
} else {
|
||||
// Just append
|
||||
localValue.value += (value && !value.endsWith(' ') ? ' ' : '') + flag
|
||||
}
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
|
||||
const onInput = () => {
|
||||
showList.value = true
|
||||
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
||||
}
|
||||
|
||||
const hideList = () => setTimeout(() => { showList.value = false; highlightedIndex.value = -1 }, 100)
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (!showList.value || !filteredOptions.value.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const pageSize = 5
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1)
|
||||
nextTick(() => {
|
||||
const el = dropdownItems.value[highlightedIndex.value]
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
||||
nextTick(() => {
|
||||
const el = dropdownItems.value[highlightedIndex.value]
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
} else if (e.key === 'PageDown') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + pageSize, filteredOptions.value.length - 1)
|
||||
nextTick(() => {
|
||||
const el = dropdownItems.value[highlightedIndex.value]
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
} else if (e.key === 'PageUp') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0)
|
||||
nextTick(() => {
|
||||
const el = dropdownItems.value[highlightedIndex.value]
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
||||
filteredOptions.value[highlightedIndex.value] : undefined
|
||||
if (selected) {
|
||||
e.preventDefault()
|
||||
appendFlag(selected.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep dropdownItems ref in sync with rendered items
|
||||
watch(filteredOptions, () => nextTick(() => {
|
||||
const items = document.querySelectorAll('.dropdown-item')
|
||||
dropdownItems.value = Array.from(items) as HTMLElement[]
|
||||
}))
|
||||
}
|
||||
</script>
|
||||
|
|
@ -153,13 +153,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
type YTDLPOption = {
|
||||
flags: string[],
|
||||
description: string | null,
|
||||
group: string | null,
|
||||
ignored: boolean,
|
||||
}
|
||||
import type { YTDLPOption } from '~/types/ytdlp'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const options = ref<YTDLPOption[]>([])
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ import 'assets/css/all.css'
|
|||
import { useStorage } from '@vueuse/core'
|
||||
import moment from 'moment'
|
||||
import * as Sentry from '@sentry/nuxt'
|
||||
import type { YTDLPOption } from '~/types/ytdlp'
|
||||
|
||||
const Year = new Date().getFullYear()
|
||||
const selectedTheme = useStorage('theme', 'auto')
|
||||
|
|
@ -235,13 +236,20 @@ watch(() => config.app.sentry_dsn, dsn => {
|
|||
onMounted(async () => {
|
||||
try {
|
||||
await handleImage(bg_enable.value)
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
try {
|
||||
const opts = await request('/api/yt-dlp/options')
|
||||
if (!opts.ok) {
|
||||
return
|
||||
}
|
||||
const data: Array<YTDLPOption> = await opts.json()
|
||||
config.ytdlp_options = data
|
||||
} catch (e) { }
|
||||
|
||||
try {
|
||||
applyPreferredColorScheme(selectedTheme.value)
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (e) { }
|
||||
})
|
||||
|
||||
watch(selectedTheme, value => {
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="tasks && tasks.length > 0">
|
||||
<div class="columns is-multiline" v-if="!toggleForm && tasks && tasks.length > 0">
|
||||
<div class="column is-12">
|
||||
<Message title="Tips" class="is-info is-background-info-80" icon="fas fa-info-circle">
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||
],
|
||||
dl_fields: [],
|
||||
folders: [],
|
||||
ytdlp_options: [],
|
||||
paused: false,
|
||||
is_loaded: false,
|
||||
});
|
||||
|
|
|
|||
9
ui/app/types/autocomplete.d.ts
vendored
Normal file
9
ui/app/types/autocomplete.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
interface Option {
|
||||
value: string
|
||||
description: string
|
||||
}
|
||||
|
||||
|
||||
type AutoCompleteOptions = Option[]
|
||||
|
||||
export type { Option, AutoCompleteOptions }
|
||||
3
ui/app/types/config.d.ts
vendored
3
ui/app/types/config.d.ts
vendored
|
|
@ -1,3 +1,4 @@
|
|||
import type { YTDLPOption } from './ytdlp';
|
||||
import type { DLField } from "./dl_fields"
|
||||
|
||||
type AppConfig = {
|
||||
|
|
@ -77,6 +78,8 @@ type ConfigState = {
|
|||
dl_fields: DLField[]
|
||||
/** List of folders where files can be saved */
|
||||
folders: string[]
|
||||
/** List of yt-dlp options */
|
||||
ytdlp_options: YTDLPOption[]
|
||||
/** Indicates if downloads are currently paused */
|
||||
paused: boolean
|
||||
/** Indicates if the configuration has been loaded */
|
||||
|
|
|
|||
8
ui/app/types/ytdlp.d.ts
vendored
Normal file
8
ui/app/types/ytdlp.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
type YTDLPOption = {
|
||||
flags: string[],
|
||||
description: string | null,
|
||||
group: string | null,
|
||||
ignored: boolean,
|
||||
}
|
||||
|
||||
export type { YTDLPOption };
|
||||
|
|
@ -13,8 +13,8 @@
|
|||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@sentry/nuxt": "^10.5.0",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"@vueuse/nuxt": "^13.6.0",
|
||||
"@vueuse/core": "^13.7.0",
|
||||
"@vueuse/nuxt": "^13.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cron-parser": "^5.3.0",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
12
uv.lock
12
uv.lock
|
|
@ -1363,7 +1363,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.4"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
|
|
@ -1371,9 +1371,9 @@ dependencies = [
|
|||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1572,11 +1572,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2025.8.11"
|
||||
version = "2025.8.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/72/de4a7f9bbfef886c7f0790b8246585310f155e4a6589dd38d846efa932e9/yt_dlp-2025.8.11.tar.gz", hash = "sha256:dc7c120a367fe55e0f711613dc80ea29d3a4e0ed8d66104cebfbe3d36e81fdfc", size = 3045769 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/af/d3c81af35ae2aef148d0ff78f001650ce5a7ca73fbd3b271eb9aab4c56ee/yt_dlp-2025.8.20.tar.gz", hash = "sha256:da873bcf424177ab5c3b701fa94ea4cdac17bf3aec5ef37b91f530c90def7bcf", size = 3037484 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/dc/4400fb3e8bccdbd0f6ecf36a8a9aea3290ad98a1c9d19664a5c92d7b2e5d/yt_dlp-2025.8.11-py3-none-any.whl", hash = "sha256:f115d2246c1ab5737772bd4845be057eebb91c0d95125a7577d92288351df7d0", size = 3281677 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/ebd888100684c10799897296e3061c19ba5559b641f8da218bf48229a815/yt_dlp-2025.8.20-py3-none-any.whl", hash = "sha256:073c97e2a3f9cd0fa6a76142c4ef46ca62b2575c37eaf80d8c3718fd6f3277eb", size = 3266841 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue