minor design update

This commit is contained in:
arabcoders 2025-07-11 19:46:41 +03:00
parent 5e14c232d7
commit dfe1acbec4
8 changed files with 265 additions and 274 deletions

View file

@ -19,40 +19,32 @@
</vTooltip>
</template>
<script setup>
<script setup lang="ts">
import { ref } from 'vue'
import { request } from '~/utils/index'
const props = defineProps({
image: {
type: String,
required: false,
},
title: {
type: String,
required: false
},
loader: {
Type: Function,
required: false,
},
privacy: {
type: Boolean,
required: false,
default: true
}
});
const props = defineProps < {
image?: string
title?: string
loader?: () => Promise < void>
privacy ?: boolean
} > ()
const cache = useSessionCache()
const toast = useNotification()
const url = ref()
const url = ref < string | null > (null)
const error = ref(false)
const isPreloading = ref(false)
let loadTimer = null;
const cancelRequest = new AbortController();
let loadTimer: ReturnType<typeof setTimeout> | null = null
const cancelRequest = new AbortController()
const defaultLoader = async () => {
const defaultLoader = async (): Promise<void> => {
try {
if (!props.image) {
return
}
if (cache.has(props.image)) {
url.value = cache.get(props.image)
return
@ -60,18 +52,16 @@ const defaultLoader = async () => {
const response = await request(props.image, { signal: cancelRequest.signal })
if (200 !== response.status) {
if (!response.ok) {
toast.error(`ImageView Request error. ${response.status}: ${response.statusText}`)
return;
return
}
const objUrl = URL.createObjectURL(await response.blob());
const objUrl = URL.createObjectURL(await response.blob())
cache.set(props.image, objUrl)
url.value = objUrl;
} catch (e) {
if ('not_needed' === e) {
url.value = objUrl
} catch (e: any) {
if (e === 'not_needed') {
return
}
console.error(e)
@ -81,25 +71,28 @@ const defaultLoader = async () => {
}
}
const stopTimer = async () => {
const stopTimer = async (): Promise<void> => {
if (error.value) {
return
}
if (url.value) {
isPreloading.value = false
url.value = null;
return;
url.value = null
return
}
await awaiter(() => isPreloading.value)
clearTimeout(loadTimer)
if (loadTimer !== null) {
clearTimeout(loadTimer)
}
isPreloading.value = false
url.value = null;
url.value = null
cancelRequest.abort('not_needed')
}
const loadContent = async () => {
const loadContent = async (): Promise<void> => {
if (props.loader) {
return props.loader()
}
@ -107,11 +100,18 @@ const loadContent = async () => {
return defaultLoader()
}
const clearCache = async () => {
const clearCache = async (): Promise<void> => {
if (!props.image) return
cache.remove(props.image)
url.value = '';
url.value = ''
return loadContent()
}
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null
const pImg = (e: Event): void => {
const target = e.target as HTMLImageElement
if (target.naturalHeight > target.naturalWidth) {
target.classList.add('image-portrait')
}
}
</script>

View file

@ -1,3 +1,9 @@
<style scoped>
code {
color: var(--bulma-code) !important
}
</style>
<template>
<div>
<div class="modal is-active" v-if="false === externalModel">
@ -31,62 +37,41 @@
</div>
</template>
<style scoped>
code {
color: var(--bulma-code) !important
}
</style>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { request } from '~/utils/index'
const toast = useNotification()
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
const emitter = defineEmits(['closeModel'])
const isLoading = ref<Boolean>(false)
const props = defineProps<{
link?: string
preset?: string
useUrl?: boolean
externalModel?: boolean
}>()
const isLoading = ref<boolean>(false)
const data = ref<any>({})
const props = defineProps({
link: {
type: String,
default: '',
required: false,
},
preset: {
type: String,
default: '',
required: false,
},
useUrl: {
type: Boolean,
default: false,
required: false,
},
externalModel: {
type: Boolean,
default: false,
required: false,
},
})
const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
return
const handle_event = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
emitter('closeModel')
}
emitter('closeModel')
}
onMounted(async () => {
onMounted(async (): Promise<void> => {
document.addEventListener('keydown', handle_event)
let url = props.useUrl ? props.link : '/api/yt-dlp/url/info';
let url = props.useUrl ? props.link || '' : '/api/yt-dlp/url/info'
if (!props.useUrl) {
let params = new URLSearchParams();
const params = new URLSearchParams()
if (props.preset) {
params.append('preset', props.preset);
params.append('preset', props.preset)
}
params.append('url', props.link);
url += '?' + params.toString();
params.append('url', props.link || '')
url += '?' + params.toString()
}
try {
@ -96,10 +81,9 @@ onMounted(async () => {
try {
data.value = JSON.parse(body)
} catch (e) {
} catch {
data.value = body
}
} catch (e: any) {
console.error(e)
toast.error(`Error: ${e.message}`)
@ -108,5 +92,7 @@ onMounted(async () => {
}
})
onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event)
})
</script>

View file

@ -1,76 +1,77 @@
<template>
<div ref="targetEl" :style="`min-height:${fixedMinHeight ? fixedMinHeight : (minHeight)}px`">
<slot v-if="shouldRender"/>
<div ref="targetEl" :style="`min-height:${fixedMinHeight || props.minHeight || 0}px`">
<slot v-if="shouldRender" />
</div>
</template>
<script>
import {nextTick, ref} from 'vue'
import {useIntersectionObserver} from '@vueuse/core'
<script setup lang="ts">
import { ref, nextTick, onBeforeUnmount } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
function onIdle(cb = () => {
}) {
if ("requestIdleCallback" in window) {
window.requestIdleCallback(cb);
const props = defineProps<{
renderOnIdle?: boolean
unrender?: boolean
minHeight?: number
unrenderDelay?: number
}>()
const shouldRender = ref(false)
const targetEl = ref<HTMLElement | null>(null)
const fixedMinHeight = ref(0)
let unrenderTimer: ReturnType<typeof setTimeout> | null = null
let renderTimer: ReturnType<typeof setTimeout> | null = null
function onIdle(cb: () => void): void {
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(cb)
} else {
setTimeout(() => nextTick(cb), 300);
setTimeout(() => nextTick(cb), 300)
}
}
export default {
props: {
renderOnIdle: Boolean,
unrender: Boolean,
minHeight: Number,
unrenderDelay: {
type: Number,
default: 6000,
},
},
setup(props) {
const shouldRender = ref(false);
const targetEl = ref();
const fixedMinHeight = ref(0);
let unrenderTimer;
let renderTimer;
const { stop } = useIntersectionObserver(targetEl, ([{ isIntersecting }]) => {
if (isIntersecting) {
if (unrenderTimer) clearTimeout(unrenderTimer)
const {stop} = useIntersectionObserver(targetEl, ([{isIntersecting}]) => {
if (isIntersecting) {
// perhaps the user re-scrolled to a component that was set to unrender. In that case stop the un-rendering timer
clearTimeout(unrenderTimer);
// if we're dealing under-rendering lets add a waiting period of 200ms before rendering.
// If a component enters the viewport and also leaves it within 200ms it will not render at all.
// This saves work and improves performance when user scrolls very fast
renderTimer = setTimeout(() => (shouldRender.value = true), props.unrender ? 200 : 0);
shouldRender.value = true;
if (!props.unrender) {
stop();
}
} else if (props.unrender) {
// if the component was set to render, cancel that
clearTimeout(renderTimer);
unrenderTimer = setTimeout(() => {
if (targetEl.value?.clientHeight) {
fixedMinHeight.value = targetEl.value.clientHeight;
}
shouldRender.value = false;
}, props.unrenderDelay);
}
}, {
rootMargin: "600px",
}
);
renderTimer = setTimeout(() => { shouldRender.value = true }, props.unrender ? 200 : 0)
if (props.renderOnIdle) {
onIdle(() => {
shouldRender.value = true;
if (!props.unrender) {
stop();
}
});
shouldRender.value = true
if (!props.unrender) {
stop()
}
}
else if (props.unrender) {
if (renderTimer) {
clearTimeout(renderTimer)
}
return {targetEl, shouldRender, fixedMinHeight};
},
};
unrenderTimer = setTimeout(() => {
if (targetEl.value?.clientHeight) {
fixedMinHeight.value = targetEl.value.clientHeight
}
shouldRender.value = false
}, props.unrenderDelay ?? 6000)
}
}, { rootMargin: '600px', })
if (props.renderOnIdle) {
onIdle(() => {
shouldRender.value = true
if (!props.unrender) {
stop()
}
})
}
onBeforeUnmount(() => {
if (renderTimer) {
clearTimeout(renderTimer)
}
if (unrenderTimer) {
clearTimeout(unrenderTimer)
}
})
</script>

View file

@ -1,67 +1,45 @@
<template>
<div class="notification" :class="message_class">
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose"></button>
<div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle">
<div class="notification" :class="props.message_class">
<button class="delete" @click="emit('close')" v-if="!props.useToggle && props.useClose"></button>
<div @click="emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="props.useToggle">
<span class="icon">
<i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i>
<i class="fas" :class="{ 'fa-arrow-up': props.toggle, 'fa-arrow-down': !props.toggle }"></i>
</span>
<span>{{ toggle ? 'Close' : 'Open' }}</span>
<span>{{ props.toggle ? 'Close' : 'Open' }}</span>
</div>
<div class="notification-title is-unselectable" :class="{ 'is-clickable': useToggle }" v-if="title || icon"
@click="useToggle ? $emit('toggle', toggle) : null">
<template v-if="icon">
<div class="notification-title is-unselectable" :class="{ 'is-clickable': props.useToggle }"
v-if="props.title || props.icon" @click="props.useToggle ? emit('toggle', props.toggle) : null">
<template v-if="props.icon">
<span class="icon-text">
<span class="icon"><i :class="icon"></i></span>
<span>{{ title }}</span>
<span class="icon"><i :class="props.icon"></i></span>
<span>{{ props.title }}</span>
</span>
</template>
<template v-else>{{ title }}</template>
<template v-else>{{ props.title }}</template>
</div>
<div class="notification-content content" v-if="false === useToggle || toggle">
<template v-if="message">{{ message }}</template>
<div class="notification-content content" v-if="!props.useToggle || props.toggle">
<template v-if="props.message">{{ props.message }}</template>
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: null,
required: false
},
icon: {
type: String,
default: null,
required: false
},
message: {
type: String,
default: null,
required: false
},
message_class: {
type: String,
default: 'is-info',
required: false
},
useToggle: {
type: Boolean,
default: false,
required: false
},
toggle: {
type: Boolean,
default: false,
required: false
},
useClose: {
type: Boolean,
default: false,
required: false
}
})
<script setup lang="ts">
const props = defineProps < {
title?: string
icon?: string
message?: string
message_class?: string
useToggle?: boolean
toggle?: boolean
useClose?: boolean
} > ()
defineEmits(['toggle', 'close'])
const emit = defineEmits < {
(e: 'toggle', value ?: boolean): void
(e: 'close'): void
}> ()
</script>

View file

@ -7,7 +7,7 @@
<div class="column is-12">
<label class="label is-inline is-unselectable" for="url">
<span class="icon"><i class="fa-solid fa-link" /></span>
URLs. separated by <span class="is-bold">{{ getSeparatorsName(separator) }}</span>
URLs separated by <span class="is-bold">{{ getSeparatorsName(separator) }}</span>
</label>
<div class="field is-grouped">
<div class="control is-expanded">
@ -69,18 +69,11 @@
</div>
</div>
<div class="column is-narrow" v-if="!config.app.basic_mode"
v-tooltip="`Automatically start downloading after adding. [${auto_start ? 'Enabled' : 'Disabled'}]`">
<input id="auto_start" type="checkbox" v-model="auto_start" :disabled="addInProgress"
class="switch is-success" />
<label for="auto_start" class="is-unselectable"></label>
</div>
<div class="column" v-if="!config.app.basic_mode">
<button type="button" class="button is-info" @click="showAdvanced = !showAdvanced"
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Options</span>
<span>Advanced Options</span>
</button>
</div>
@ -96,42 +89,30 @@
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="field has-addons">
<div class="control">
<label class="button is-static">
<span class="icon"><i class="fa-solid fa-object-ungroup" /></span>
<span>Separator</span>
</label>
</div>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select class="is-fullwidth" :disabled="!socket.isConnected || addInProgress" v-model="separator">
<option v-for="(sep, index) in separators" :key="`sep-${index}`" :value="sep.value">
{{ sep.name }} ({{ sep.value }})
</option>
</select>
</div>
</div>
<div class="field">
<input id="auto_start" type="checkbox" v-model="auto_start" :disabled="addInProgress"
class="switch is-success" />
<label for="auto_start" class="is-unselectable">
{{ auto_start ? 'Auto start' : 'Manual start' }}
</label>
</div>
<span class="help is-bold is-unselectable">
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this to separate multiple URLs in the input field.</span>
<span class="is-bold">Whether to start the download automatically or wait.</span>
</span>
</div>
<div class="column is-8-tablet is-12-mobile">
<div class="field has-addons">
<div class="control">
<label for="output_format" v-tooltip="'Default: ' + config.app.output_template"
class="button is-static is-unselectable">
<label for="output_format" class="button is-static is-unselectable">
<span class="icon"><i class="fa-solid fa-file" /></span>
<span>Template</span>
</label>
</div>
<div class="control is-expanded">
<input type="text" class="input" v-model="form.template" id="output_format"
:disabled="!socket.isConnected || addInProgress"
placeholder="Uses default output template naming if empty.">
:disabled="!socket.isConnected || addInProgress" :placeholder="get_output_template()">
</div>
</div>
<span class="help is-bold is-unselectable">
@ -251,6 +232,7 @@
import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core'
import type { item_request } from '~/@types/item'
import { getSeparatorsName, separators } from '~/utils/utils'
const props = defineProps<{ item?: Partial<item_request> }>()
const emitter = defineEmits<{
@ -261,20 +243,6 @@ const emitter = defineEmits<{
const config = useConfigStore()
const socket = useSocketStore()
const toast = useNotification()
const box = useConfirm()
const separators = [
{ name: 'Comma', value: ',', },
{ name: 'Semicolon', value: ';', },
{ name: 'Colon', value: ':', },
{ name: 'Pipe', value: '|', },
{ name: 'Space', value: ' ', }
]
const getSeparatorsName = (value: string): string => {
const sep = separators.find(s => s.value === value)
return sep ? `${sep.name} (${value})` : 'Unknown'
}
const showAdvanced = useStorage<boolean>('show_advanced', false)
const separator = useStorage<string>('url_separator', separators[0].value)
@ -384,7 +352,6 @@ const reset_config = () => {
} as item_request
showAdvanced.value = false
separator.value = separators[0].value
toast.success('Local configuration has been reset.')
dialog_confirm.value.visible = false
@ -477,4 +444,15 @@ const removeFromArchive = async (url: string) => {
toast.error(`Error: ${e.message}`)
}
}
const get_output_template = () => {
if (form.value.preset && !hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.value.preset)
if (preset && preset.template) {
return preset.template
}
}
return config.app.output_template || '%(title)s.%(ext)s'
}
</script>

View file

@ -51,7 +51,7 @@
<div class="field">
<label class="label is-unselectable" for="random_bg_opacity">
Background visibility <code>{{ parseFloat(1.0 - bg_opacity).toFixed(2) }}</code>
Background visibility <code>{{ parseFloat(String(1.0 - bg_opacity)).toFixed(2) }}</code>
</label>
<div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50"
@ -69,6 +69,19 @@
</div>
</div>
<div class="field">
<label class="label is-unselectable" for="show_thumbnail">URLs Separator</label>
<div class="control">
<div class="select is-fullwidth">
<select class="is-fullwidth" v-model="separator">
<option v-for="(sep, index) in separators" :key="`sep-${index}`" :value="sep.value">
{{ sep.name }} ({{ sep.value }})
</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
@ -126,24 +139,20 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import { POSITION } from 'vue-toastification'
import { separators } from '~/utils/utils'
defineProps({
isLoading: {
type: Boolean,
required: true
}
})
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.95)
const selectedTheme = useStorage('theme', 'auto')
const allow_toasts = useStorage('allow_toasts', true)
const reduce_confirm = useStorage('reduce_confirm', false)
const toast_position = useStorage('toast_position', POSITION.TOP_RIGHT)
const toast_dismiss_on_click = useStorage('toast_dismiss_on_click', true)
const show_thumbnail = useStorage('show_thumbnail', true)
defineProps<{ isLoading: boolean }>()
const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
const selectedTheme = useStorage<'auto' | 'light' | 'dark'>('theme', 'auto')
const allow_toasts = useStorage<boolean>('allow_toasts', true)
const reduce_confirm = useStorage<boolean>('reduce_confirm', false)
const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true)
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const separator = useStorage<string>('url_separator', separators[0].value)
</script>

View file

@ -18,27 +18,28 @@
</p>
<p class="control">
<button class="button is-danger is-light" v-tooltip.bottom="'Filter'"
@click="toggleFilter = !toggleFilter">
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
<span class="is-hidden-mobile">Filter</span>
</button>
</p>
<p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode">
<button class="button is-warning" @click="pauseDownload" v-if="false === config.paused"
v-tooltip.bottom="'Pause non-active downloads.'">
<button class="button is-warning" @click="pauseDownload" v-if="false === config.paused">
<span class="icon"><i class="fas fa-pause" /></span>
<span class="is-hidden-mobile">Pause</span>
</button>
<button class="button is-danger" @click="socket.emit('resume', {})" v-else
v-tooltip.bottom="'Resume downloading.'">
<span class="icon"><i class="fas fa-play" /></span>
<span class="is-hidden-mobile">Resume</span>
</button>
</p>
<p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode">
<button v-tooltip.bottom="'Toggle download form'" class="button is-primary has-tooltip-bottom"
@click="config.showForm = !config.showForm">
<button class="button is-primary has-tooltip-bottom" @click="config.showForm = !config.showForm">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span class="is-hidden-mobile">New Download</span>
</button>
</p>
@ -47,6 +48,7 @@
@click="() => changeDisplay()">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-table': display_style === 'cards', 'fa-table-list': display_style === 'list' }" /></span>
<span class="is-hidden-mobile">{{ display_style === 'cards' ? 'Cards' : 'List' }}</span>
</button>
</p>
@ -70,6 +72,9 @@
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :useUrl="info_view.useUrl"
@closeModel="close_info()" />
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
:html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
@cancel="() => dialog_confirm.visible = false" />
</div>
</template>
@ -91,9 +96,16 @@ const info_view = ref({
preset: '',
useUrl: false,
}) as Ref<{ url: string, preset: string, useUrl: boolean }>
const item_form = ref<item_request|object>({})
const item_form = ref<item_request | object>({})
const query = ref()
const toggleFilter = ref(false)
const dialog_confirm = ref({
visible: false,
title: 'Confirm Action',
confirm: () => { },
html_message: '',
options: [],
})
watch(toggleFilter, () => {
if (!toggleFilter.value) {
@ -123,12 +135,18 @@ watch(() => stateStore.queue, () => {
useHead({ title: `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers} | ${Object.keys(stateStore.history).length || 0} )` })
}, { deep: true })
const pauseDownload = () => {
if (false === box.confirm('Pause all non-active downloads?')) {
return false
}
socket.emit('pause', {})
const pauseDownload = () => {
dialog_confirm.value.visible = true
dialog_confirm.value.html_message = `<span class="is-bold">Pause All non-active downloads?</span><br>
<span class="has-text-danger">
<span class="icon"><i class="fa-solid fa-exclamation-triangle"></i></span>
<span>This will not stop downloads that are currently in progress.</span>
</span>`
dialog_confirm.value.confirm = () => {
socket.emit('pause', {})
dialog_confirm.value.visible = false
}
}
const close_info = () => {

View file

@ -1,5 +1,26 @@
import type { convert_args_response } from "~/@types/responses";
const separators = [
{ name: 'Comma', value: ',', },
{ name: 'Semicolon', value: ';', },
{ name: 'Colon', value: ':', },
{ name: 'Pipe', value: '|', },
{ name: 'Space', value: ' ', }
]
/**
* Get the name of a separator based on its value
*
* @param {string} value - The separator value
*
* @returns {string} The name of the separator, or 'Unknown' if not found
*/
const getSeparatorsName = (value: string): string => {
const sep = separators.find(s => s.value === value)
return sep ? `${sep.name} (${value})` : 'Unknown'
}
/**
* Convert options to JSON
*
@ -21,4 +42,4 @@ const convertCliOptions = async (opts: string): Promise<convert_args_response> =
return data
}
export { convertCliOptions }
export { convertCliOptions, separators, getSeparatorsName }