minor design update

This commit is contained in:
arabcoders 2025-06-13 21:22:55 +03:00
parent fb51fc3b1f
commit a478e8e58d
13 changed files with 320 additions and 256 deletions

View file

@ -1,40 +0,0 @@
import logging
from .config import Config
from .DownloadQueue import DownloadQueue
from .encoder import Encoder
from .ItemDTO import Item
LOG = logging.getLogger("common")
class Common:
"""
This class is used to share common methods between the socket and the API gateways.
"""
def __init__(
self,
queue: DownloadQueue | None = None,
encoder: Encoder | None = None,
config: Config | None = None,
):
super().__init__()
self.queue: DownloadQueue = queue or DownloadQueue.get_instance()
self.encoder: Encoder = encoder or Encoder()
config: Config = config or Config.get_instance()
self.default_preset: str = config.default_preset
async def add(self, item: Item) -> dict[str, str]:
"""
Add an item to the download queue.
Args:
item (Item): The item to be added to the queue.
Returns:
dict[str, str]: The status of the download.
{ "status": "text" }
"""
return await self.queue.add(item=item)

8
ui/@types/logs.ts Normal file
View file

@ -0,0 +1,8 @@
type log_line = {
id: number,
line: string,
datetime?: string,
}
export type { log_line };

17
ui/@types/tasks.ts Normal file
View file

@ -0,0 +1,17 @@
type task_item = {
id: string,
name: string,
url: string,
preset?: string,
folder?: string,
template?: string,
cli?: string,
timer?: string,
in_progress?: boolean,
}
type exported_task = task_item & { _type: string, _version: string }
type error_response = { error: string }
export type { task_item, exported_task, error_response };

View file

@ -7,6 +7,7 @@
width: 80vw; width: 80vw;
} }
</style> </style>
<template> <template>
<div class="content"> <div class="content">
<h1 class="has-text-white">Not downloaded yet.</h1> <h1 class="has-text-white">Not downloaded yet.</h1>
@ -14,10 +15,8 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { onMounted, onUnmounted } from 'vue' defineProps({
const props = defineProps({
url: { url: {
type: String, type: String,
required: true, required: true,
@ -26,12 +25,13 @@ const props = defineProps({
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel'])
const eventFunc = e => { const handle_event = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key !== 'Escape') {
emitter('closeModel') return
} }
emitter('closeModel')
} }
onMounted(async () => window.addEventListener('keydown', eventFunc)) onMounted(() => document.addEventListener('keydown', handle_event))
onUnmounted(() => window.removeEventListener('keydown', eventFunc)) onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
</script> </script>

View file

@ -1,47 +1,50 @@
<template> <template>
<div> <div>
<div class="modal is-active" v-if="false === externalModel"> <div class="modal is-active" v-if="false === externalModel">
<div class="modal-background" @click="closeModal"></div> <div class="modal-background" @click="emitter('closeModel')"></div>
<div class="modal-content" style="width:60vw;"> <div class="modal-content" style="width:60vw;">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading"> <div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin"></i> <i class="fas fa-circle-notch fa-spin"></i>
</div> </div>
<div v-else> <div v-else>
<div class="p-0 m-0" style="position: relative"> <div class="content p-0 m-0" style="position: relative">
<div class="content" style="white-space: pre;"> <pre><code class="p-4 is-block" v-text="data" />
<code class="p-4 is-block" style="overflow: scroll;" v-text="data" /> <button class="button m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
style="position: absolute; top:0; right:0;"> style="position: absolute; top:0; right:0;">
<span class="icon"><i class="fas fa-copy"></i></span> <span class="icon"><i class="fas fa-copy"></i></span>
</button> </button>
</div> </pre>
</div> </div>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="closeModal"></button> <button class="modal-close is-large" aria-label="close" @click="emitter('closeModel')"></button>
</div> </div>
<div style="width:70vw; height: 80vh;" v-else> <div style="width:70vw; height: 80vh;" v-else>
<div class="p-0 m-0" style="position: relative"> <div class="content p-0 m-0" style="position: relative">
<div class="content" style="white-space: pre;"> <pre><code class="p-4 is-block" v-text="data" /></pre>
<code class="p-4 is-block" v-text="data" /> <button class="button m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(data, null, 4))" style="position: absolute; top:0; right:0;">
style="position: absolute; top:0; right:0;"> <span class="icon"><i class="fas fa-copy"></i></span>
<span class="icon"><i class="fas fa-copy"></i></span> </button>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <style scoped>
code {
color: var(--bulma-code) !important
}
</style>
<script setup lang="ts">
import { request } from '~/utils/index' import { request } from '~/utils/index'
const emitter = defineEmits(['closeModel'])
const isLoading = ref(false)
const data = ref({})
const toast = useNotification() const toast = useNotification()
const emitter = defineEmits(['closeModel'])
const isLoading = ref<Boolean>(false)
const data = ref<any>({})
const props = defineProps({ const props = defineProps({
link: { link: {
type: String, type: String,
@ -60,16 +63,15 @@ const props = defineProps({
}, },
}) })
const closeModal = () => emitter('closeModel') const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
const eventFunc = e => { return
if (e.key === 'Escape') {
emitter('closeModel')
} }
emitter('closeModel')
} }
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', eventFunc) document.addEventListener('keydown', handle_event)
const url = props.useUrl ? props.link : '/api/yt-dlp/url/info?url=' + encodePath(props.link) const url = props.useUrl ? props.link : '/api/yt-dlp/url/info?url=' + encodePath(props.link)
@ -84,7 +86,7 @@ onMounted(async () => {
data.value = body data.value = body
} }
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`)
} finally { } finally {
@ -92,5 +94,5 @@ onMounted(async () => {
} }
}) })
onUnmounted(() => window.removeEventListener('keydown', eventFunc)) onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
</script> </script>

View file

@ -1,30 +1,31 @@
<style> <style scoped>
.is-image { img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
margin: auto; margin: auto;
display: block; display: block;
} }
</style> </style>
<template> <template>
<div> <div>
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading"> <div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin"></i> <i class="fas fa-circle-notch fa-spin"></i>
</div> </div>
<div v-else> <div v-else>
<img :src="image" class="is-image"> <img :src="image">
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { request } from '~/utils/index' import { request } from '~/utils/index'
const emitter = defineEmits(['closeModel'])
const isLoading = ref(false)
const data = ref({})
const toast = useNotification() const toast = useNotification()
const image = ref('') const emitter = defineEmits(['closeModel'])
const isLoading = ref<boolean>(false)
const image = ref<string>('')
const props = defineProps({ const props = defineProps({
link: { link: {
@ -33,15 +34,15 @@ const props = defineProps({
}, },
}) })
const eventFunc = e => { const handle_event = (e: KeyboardEvent) => {
if ('Escape' === e.key) { if (e.key !== 'Escape') {
emitter('closeModel') return
} }
emitter('closeModel')
} }
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', eventFunc) document.addEventListener('keydown', handle_event)
const url = props.link.startsWith('/') ? props.link : '/api/thumbnail?url=' + encodePath(props.link) const url = props.link.startsWith('/') ? props.link : '/api/thumbnail?url=' + encodePath(props.link)
@ -54,7 +55,7 @@ onMounted(async () => {
} }
image.value = URL.createObjectURL(await imgRequest.blob()) image.value = URL.createObjectURL(await imgRequest.blob())
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`)
} finally { } finally {
@ -62,5 +63,5 @@ onMounted(async () => {
} }
}) })
onUnmounted(() => window.removeEventListener('keydown', eventFunc)) onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
</script> </script>

View file

@ -2,19 +2,19 @@
<div> <div>
<div class="modal is-active"> <div class="modal is-active">
<div class="model-title" v-if="title" /> <div class="model-title" v-if="title" />
<div class="modal-background" @click="closeModal"></div> <div class="modal-background" @click="emitter('close')"></div>
<div class="modal-content" style="width:70vw;"> <div class="modal-content" style="width:70vw;">
<slot /> <slot />
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="closeModal"></button> <button class="modal-close is-large" aria-label="close" @click="emitter('close')"></button>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
const emitter = defineEmits(['close']) const emitter = defineEmits(['close'])
const props = defineProps({ defineProps({
title: { title: {
type: String, type: String,
default: '', default: '',
@ -22,14 +22,13 @@ const props = defineProps({
}, },
}) })
const closeModal = () => emitter('close') const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
const eventFunc = e => { return
if (e.key === 'Escape') {
emitter('close')
} }
emitter('close')
} }
onMounted(() => window.addEventListener('keydown', eventFunc)) onMounted(() => document.addEventListener('keydown', handle_event))
onUnmounted(() => window.removeEventListener('keydown', eventFunc)) onBeforeUnmount(() => document.removeEventListener('keydown', handle_event))
</script> </script>

View file

@ -10,8 +10,8 @@
</style> </style>
<template> <template>
<div v-if="infoLoaded"> <div v-if="infoLoaded">
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls crossorigin="anonymous" <video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls
preload="auto" autoplay> crossorigin="anonymous" preload="auto" autoplay>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror" <source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" /> :type="source.type" />
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label" <track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label"
@ -25,11 +25,35 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { onMounted, onUpdated, ref, onUnmounted } from 'vue'
import Hls from 'hls.js' import Hls from 'hls.js'
import { makeDownload } from '~/utils/index' import { makeDownload } from '~/utils/index'
type video_track_element = {
file: string,
kind: string,
label: string,
lang: string,
}
type video_source_element = {
src: string,
type: string,
onerror: (e: Event) => void,
}
type file_info = {
title: string,
mimetype: string,
sidecar?: {
image?: Array<{ file: string }>,
text?: Array<{ file: string }>,
subtitle?: Array<{ name: string, lang: string, file: string }>,
}
error?: string,
}
const config = useConfigStore() const config = useConfigStore()
const toast = useNotification() const toast = useNotification()
@ -42,9 +66,9 @@ const props = defineProps({
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel'])
const video = ref(null) const video = useTemplateRef<HTMLMediaElement>('video')
const tracks = ref([]) const tracks = ref<Array<video_track_element>>([])
const sources = ref([]) const sources = ref<Array<video_source_element>>([])
const thumbnail = ref('/images/placeholder.png') const thumbnail = ref('/images/placeholder.png')
const artist = ref('') const artist = ref('')
@ -54,19 +78,39 @@ const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
const volume = useStorage('player_volume', 1) const volume = useStorage('player_volume', 1)
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox') const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
const infoLoaded = ref(false) const infoLoaded = ref(false)
const destroyed = ref(false)
let hls = null let hls: Hls | null = null
const eventFunc = e => { const handle_event = (e: KeyboardEvent) => {
if ('Escape' === e.key) { if (e.key !== 'Escape') {
emitter('closeModel') return
} }
emitter('closeModel')
}
const volume_change_handler = () => {
if (!video.value) {
return
}
volume.value = video.value.volume
if (false === ("mediaSession" in navigator)) {
return
}
navigator.mediaSession.setPositionState({
duration: video.value.duration,
playbackRate: video.value.playbackRate,
position: video.value.currentTime,
})
} }
onMounted(async () => { onMounted(async () => {
const req = await request(makeDownload(config, props.item, 'api/file/info')) const req = await request(makeDownload(config, props.item, 'api/file/info'))
const response = await req.json() const response: file_info = await req.json()
if (!req.ok) { if (!req.ok) {
toast.error(`Failed to fetch video info. ${response?.error}`) toast.error(`Failed to fetch video info. ${response?.error}`)
@ -91,13 +135,13 @@ onMounted(async () => {
sources.value.push({ sources.value.push({
src: makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8'), src: makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8'),
type: allowedCodec ? response.mimetype : 'application/x-mpegURL', type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
onerror: e => src_error(e), onerror: (e: Event) => src_error(),
}) })
} else { } else {
sources.value.push({ sources.value.push({
src: makeDownload(config, props.item, 'api/download'), src: makeDownload(config, props.item, 'api/download'),
type: response.mimetype, type: response.mimetype,
onerror: e => src_error(e), onerror: e => src_error(),
}) })
} }
@ -122,7 +166,7 @@ onMounted(async () => {
isAudio.value = true isAudio.value = true
} }
response?.sidecar?.subtitle?.forEach((cap, id) => { response.sidecar?.subtitle?.forEach((cap, _) => {
tracks.value.push({ tracks.value.push({
kind: "captions", kind: "captions",
label: cap.name, label: cap.name,
@ -139,30 +183,35 @@ onMounted(async () => {
await nextTick() await nextTick()
prepareVideoPlayer() prepareVideoPlayer()
window.addEventListener('keydown', eventFunc) document.addEventListener('keydown', handle_event)
}) })
onUpdated(() => prepareVideoPlayer()) onUpdated(() => prepareVideoPlayer())
onUnmounted(() => { onBeforeUnmount(() => {
if (hls) { if (hls) {
hls.destroy() hls.destroy()
} }
window.removeEventListener('keydown', eventFunc) document.removeEventListener('keydown', handle_event)
if (title.value) { if (title.value) {
window.document.title = 'YTPTube' window.document.title = 'YTPTube'
} }
if (video.value) {
try { if (!video.value) {
video.value.pause() return
video.value.querySelectorAll("source").forEach(source => source.removeAttribute("src")) }
video.value.load() destroyed.value = true
}
catch (e) { try {
console.error(e) video.value.pause()
} video.value.querySelectorAll("source").forEach(source => source.removeAttribute("src"))
video.value.removeEventListener('volumechange', volume_change_handler)
video.value.load()
}
catch (e) {
console.error(e)
} }
}) })
@ -175,9 +224,7 @@ const prepareVideoPlayer = () => {
return return
} }
let mediaMetadata = { let mediaMetadata: MediaMetadataInit = { title: title.value }
title: title.value,
}
if (thumbnail.value) { if (thumbnail.value) {
mediaMetadata.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }] mediaMetadata.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }]
@ -192,22 +239,31 @@ const prepareVideoPlayer = () => {
window.document.title = `YTPTube - Playing: ${title.value}` window.document.title = `YTPTube - Playing: ${title.value}`
} }
video.value.volume = volume.value if (!video.value) {
return
}
video.value.addEventListener('volumechange', () => { video.value.volume = volume.value
volume.value = video.value.volume video.value.addEventListener('volumechange', volume_change_handler)
})
} }
const src_error = () => { const src_error = async () => {
if (hls) { if (hls) {
return return
} }
await nextTick()
if (destroyed.value) {
return
}
console.warn('Direct play failed, trying HLS.') console.warn('Direct play failed, trying HLS.')
attach_hls(makeDownload(config, props.item, 'm3u8')) attach_hls(makeDownload(config, props.item, 'm3u8'))
} }
const attach_hls = link => { const attach_hls = (link: string) => {
if (!video.value) {
return
}
hls = new Hls({ hls = new Hls({
debug: false, debug: false,
enableWorker: true, enableWorker: true,

View file

@ -60,17 +60,17 @@
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/console" @click.native="e => changeRoute(e)"
v-if="config.app.console_enabled">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Terminal</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/logs" @click.native="e => changeRoute(e)" <NuxtLink class="navbar-item" to="/logs" @click.native="e => changeRoute(e)"
v-if="config.app.file_logging"> v-if="config.app.file_logging">
<span class="icon"><i class="fa-solid fa-file-lines" /></span> <span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span> <span>Logs</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/console" @click.native="e => changeRoute(e)"
v-if="config.app.console_enabled">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Console</span>
</NuxtLink>
</div> </div>
</div> </div>

View file

@ -340,7 +340,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
} }
} }
const popstateHandler = e => { const event_handler = e => {
if (!e.state) { if (!e.state) {
return return
@ -358,12 +358,10 @@ onMounted(async () => {
await reloadContent(path.value, true) await reloadContent(path.value, true)
} }
window.addEventListener('popstate', popstateHandler) document.addEventListener('popstate', event_handler)
}) })
onUnmounted(() => { onBeforeUnmount(() => document.removeEventListener('popstate', event_handler))
window.removeEventListener('popstate', popstateHandler)
})
const makeBreadCrumb = path => { const makeBreadCrumb = path => {
const baseLink = '/' const baseLink = '/'

View file

@ -10,11 +10,11 @@
<h1 class="title is-4"> <h1 class="title is-4">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Terminal</span> <span>Console</span>
</span> </span>
</h1> </h1>
<div class="subtitle is-6 is-unselectable"> <div class="subtitle is-6 is-unselectable">
You can use this terminal window to execute non-interactive commands. The interface is jailed to the You can use this console window to execute non-interactive commands. The interface is jailed to the
<code>yt-dlp</code> <code>yt-dlp</code>
</div> </div>
</div> </div>
@ -25,8 +25,9 @@
<span class="icon"><i class="fa-solid fa-terminal" /></span> Console Output <span class="icon"><i class="fa-solid fa-terminal" /></span> Console Output
</p> </p>
<p class="card-header-icon"> <p class="card-header-icon">
<span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput"> <span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput()">
<i class="fa-solid fa-broom" /></span> <i class="fa-solid fa-broom" />
</span>
</p> </p>
</header> </header>
<section class="card-content p-0 m-0"> <section class="card-content p-0 m-0">
@ -34,12 +35,6 @@
</section> </section>
<section class="card-content p-1 m-1"> <section class="card-content p-1 m-1">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control">
<span class="icon-text input is-unselectable">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>yt-dlp</span>
</span>
</div>
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input" v-model="command" placeholder="--help" autocomplete="off" <input type="text" class="input" v-model="command" placeholder="--help" autocomplete="off"
ref="command_input" @keydown.enter="runCommand" :disabled="isLoading" id="command"> ref="command_input" @keydown.enter="runCommand" :disabled="isLoading" id="command">
@ -60,22 +55,24 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import "@xterm/xterm/css/xterm.css" import "@xterm/xterm/css/xterm.css"
import { Terminal } from "@xterm/xterm" import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit" import { FitAddon } from "@xterm/addon-fit"
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const terminal = ref()
const terminalFit = ref()
const command = ref('')
const terminal_window = ref()
const command_input = ref()
const isLoading = ref(false)
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.85)
const terminal = ref<Terminal>()
const terminalFit = ref<FitAddon>()
const command = ref<string>('')
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window')
const command_input = useTemplateRef<HTMLInputElement>('command_input')
const isLoading = ref<boolean>(false)
watch(() => isLoading.value, async value => { watch(() => isLoading.value, async value => {
if (value) { if (value) {
@ -100,11 +97,11 @@ watch(() => config.app.console_enabled, async () => {
await navigateTo('/') await navigateTo('/')
}) })
const reSizeTerminal = () => { const handle_event = () => {
if (!terminal.value) { if (!terminal.value) {
return return
} }
terminalFit.value.fit() terminalFit.value?.fit()
} }
const runCommand = async () => { const runCommand = async () => {
@ -128,13 +125,14 @@ const runCommand = async () => {
cursorStyle: 'underline', cursorStyle: 'underline',
cols: 108, cols: 108,
rows: 10, rows: 10,
buffer: 1000,
disableStdin: true, disableStdin: true,
scrollback: 1000, scrollback: 1000,
}) })
terminalFit.value = new FitAddon() terminalFit.value = new FitAddon()
terminal.value.loadAddon(terminalFit.value) terminal.value.loadAddon(terminalFit.value)
terminal.value.open(terminal_window.value) if (terminal_window.value) {
terminal.value.open(terminal_window.value)
}
terminalFit.value.fit(); terminalFit.value.fit();
} }
@ -149,7 +147,7 @@ const runCommand = async () => {
terminal.value.writeln(`$ yt-dlp ${command.value}`) terminal.value.writeln(`$ yt-dlp ${command.value}`)
} }
const clearOutput = async (withCommand = false) => { const clearOutput = async (withCommand: boolean = false) => {
if (terminal.value) { if (terminal.value) {
terminal.value.clear() terminal.value.clear()
} }
@ -168,7 +166,7 @@ const focusInput = () => {
command_input.value.focus() command_input.value.focus()
} }
const writer = s => { const writer = (s: string) => {
if (!terminal.value) { if (!terminal.value) {
return return
} }
@ -188,23 +186,23 @@ watch(() => config.app.basic_mode, async () => {
}) })
onMounted(async () => { onMounted(async () => {
window.addEventListener('resize', reSizeTerminal); document.addEventListener('resize', handle_event);
focusInput() focusInput()
socket.off('cli_close', loader) socket.off('cli_close', loader)
socket.off('cli_output', writer) socket.off('cli_output', writer)
socket.on('cli_close', loader) socket.on('cli_close', loader)
socket.on('cli_output', writer) socket.on('cli_output', writer)
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body').setAttribute("style", `opacity: 1.0`) document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
} }
}) })
onUnmounted(() => { onBeforeUnmount(() => {
socket.off('cli_close', loader) socket.off('cli_close', loader)
socket.off('cli_output', writer) socket.off('cli_output', writer)
window.removeEventListener('resize', reSizeTerminal) document.removeEventListener('resize', handle_event)
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body').setAttribute("style", `opacity: ${bg_opacity.value}`) document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
} }
}); });
</script> </script>

View file

@ -89,19 +89,19 @@ code {
</span> </span>
<span v-for="log in filteredItems" :key="log.id" class="is-block"> <span v-for="log in filteredItems" :key="log.id" class="is-block">
<template v-if="log?.datetime">[<span class="has-tooltip" :title="log.datetime">{{ moment(log.datetime).format('HH:mm:ss') }}</span>]</template> <template v-if="log?.datetime">[<span class="has-tooltip" :title="log.datetime">{{ moment(log.datetime).format('HH:mm:ss') }}</span>]</template>
{{ log.line }} {{ log.line }}
</span> </span>
<span class="is-block" v-if="filteredItems.length < 1"> <span class="is-block" v-if="filteredItems.length < 1">
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query"> <span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
<span class="notification-title is-danger"> <span class="notification-title is-danger">
<span class="icon"><i class="fas fa-filter"/></span> <span class="icon"><i class="fas fa-filter" /></span>
No logs match this query: <u>{{ query }}</u> No logs match this query: <u>{{ query }}</u>
</span> </span>
</span> </span>
<span v-else> <span v-else>
<span class="has-text-danger">No logs available</span></span> <span class="has-text-danger">No logs available</span></span>
</span> </span>
</code> </code>
<div ref="bottomMarker"></div> <div ref="bottomMarker"></div>
</div> </div>
</div> </div>
@ -109,30 +109,45 @@ code {
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import moment from 'moment' import moment from 'moment'
import { request } from '~/utils/index' import { request } from '~/utils/index'
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { log_line } from '~/@types/logs'
let scrollTimeout = null let scrollTimeout: NodeJS.Timeout | null = null
const toast = useNotification() const toast = useNotification()
const socket = useSocketStore() const socket = useSocketStore()
const config = useConfigStore() const config = useConfigStore()
const route = useRoute()
const logs = ref([]) const logs = ref<Array<log_line>>([])
const offset = ref(0) const offset = ref<number>(0)
const loading = ref(false) const loading = ref<boolean>(false)
const logContainer = ref(null) const logContainer = useTemplateRef<HTMLDivElement>('logContainer')
const bottomMarker = ref(null) const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker')
const autoScroll = ref(true) const autoScroll = ref<boolean>(true)
const textWrap = ref(true) const textWrap = ref<boolean>(true)
const reachedEnd = ref(false) const reachedEnd = ref<boolean>(false)
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage<number>('random_bg_opacity', 0.85)
const query = ref<string>((() => {
const filter = route.query.filter ?? ''
if (!filter) {
return ''
}
if (typeof filter === 'string') {
return filter.trim()
}
if (Array.isArray(filter) && filter.length > 0) {
return filter[0]?.trim() ?? ''
}
return ''
})())
const query = ref(useRoute().query.filter ?? '')
const toggleFilter = ref(false) const toggleFilter = ref(false)
watch(toggleFilter, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
@ -161,9 +176,11 @@ const filteredItems = computed(() => {
const context = contextMatch ? parseInt(contextMatch[1], 10) : 0 const context = contextMatch ? parseInt(contextMatch[1], 10) : 0
const searchTerm = raw.replace(/context:\d+/, '').trim() const searchTerm = raw.replace(/context:\d+/, '').trim()
if (!searchTerm) return logs.value if (!searchTerm) {
return logs.value
}
const result = [] const result: Array<log_line> = []
const matchedIndexes = new Set() const matchedIndexes = new Set()
logs.value.forEach((log, i) => { logs.value.forEach((log, i) => {
@ -174,7 +191,7 @@ const filteredItems = computed(() => {
} }
}) })
Array.from(matchedIndexes).sort((a, b) => a - b).forEach(index => { Array.from(matchedIndexes).sort((a: any, b: any) => a - b).forEach((index: any) => {
result.push(logs.value[index]) result.push(logs.value[index])
}) })
@ -251,11 +268,11 @@ const handleScroll = () => {
} }
} }
const scrollToBottom = (fast=false) => { const scrollToBottom = (fast = false) => {
autoScroll.value = true autoScroll.value = true
nextTick(() => { nextTick(() => {
if (bottomMarker.value) { if (bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto': 'smooth' }) bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' })
} }
}) })
} }
@ -263,7 +280,8 @@ const scrollToBottom = (fast=false) => {
onMounted(async () => { onMounted(async () => {
await fetchLogs() await fetchLogs()
socket.emit('subscribe', 'log_lines') socket.emit('subscribe', 'log_lines')
socket.on('log_lines', data => {
socket.on('log_lines', (data: any) => {
logs.value.push(data) logs.value.push(data)
nextTick(() => { nextTick(() => {
@ -271,17 +289,19 @@ onMounted(async () => {
bottomMarker.value.scrollIntoView({ behavior: 'smooth' }) bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
} }
}) })
}) })
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body').setAttribute("style", `opacity: 1.0`) document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
} }
}) })
onUnmounted(() => { onBeforeUnmount(() => {
socket.emit('unsubscribe', 'log_lines') socket.emit('unsubscribe', 'log_lines')
socket.off('log_lines') socket.off('log_lines')
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body').setAttribute("style", `opacity: ${bg_opacity.value}`) document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
} }
}) })

View file

@ -36,7 +36,7 @@ div.is-centered {
</p> </p>
<p class="control"> <p class="control">
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }" <button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading" v-if="tasks && tasks.length > 0"> :disabled="!socket.isConnected || isLoading" v-if="tasks && tasks.length > 0">
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
</button> </button>
@ -97,9 +97,8 @@ div.is-centered {
<span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer"> <span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer">
{{ tryParse(item.timer) }} {{ tryParse(item.timer) }}
</span> </span>
<span v-else class="has-text-danger"> <span v-else class="has-text-warning">
<span class="icon"><i class="fa-solid fa-exclamation-triangle" /></span> No timer is set
<span>No timer is set</span>
</span> </span>
</td> </td>
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
@ -155,9 +154,16 @@ div.is-centered {
<div class="card-content is-flex-grow-1"> <div class="card-content is-flex-grow-1">
<div class="content"> <div class="content">
<p class="is-text-overflow"> <p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-clock" /></span> <span class="icon">
<span v-if="item.timer">{{ tryParse(item.timer) }} - {{ item.timer }}</span> <i class="fa-solid"
<span v-else>No timer is set</span> :class="{ 'fa-clock': item.timer, 'has-text-danger fa-exclamation-triangle': !item.timer }" />
</span>
<span v-if="item.timer">
<NuxtLink target="_blank" :to="`https://crontab.guru/#${item.timer.replace(/ /g, '_')}`">
{{ item.timer }} - {{ tryParse(item.timer) }}
</NuxtLink>
</span>
<span class="has-text-warning" v-else>No timer is set</span>
</p> </p>
<p class="is-text-overflow" v-if="item.folder"> <p class="is-text-overflow" v-if="item.folder">
<span class="icon"><i class="fa-solid fa-folder" /></span> <span class="icon"><i class="fa-solid fa-folder" /></span>
@ -214,25 +220,26 @@ div.is-centered {
</main> </main>
</template> </template>
<script setup> <script setup lang="ts">
import moment from 'moment' import moment from 'moment'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { CronExpressionParser } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import { request } from '~/utils/index' import { request } from '~/utils/index'
import type { task_item, exported_task, error_response } from '~/@types/tasks'
const box = useConfirm()
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm()
const tasks = ref([]) const tasks = ref<Array<task_item>>([])
const task = ref({}) const task = ref<task_item | Object>({})
const taskRef = ref('') const taskRef = ref<string>('')
const toggleForm = ref(false) const toggleForm = ref<boolean>(false)
const isLoading = ref(true) const isLoading = ref<boolean>(true)
const initialLoad = ref(true) const initialLoad = ref<boolean>(true)
const addInProgress = ref(false) const addInProgress = ref<boolean>(false)
const display_style = useStorage("tasks_display_style", "cards") const display_style = useStorage<string>("tasks_display_style", "cards")
const remove_keys = ['in_progress'] const remove_keys = ['in_progress']
watch(() => config.app.basic_mode, async () => { watch(() => config.app.basic_mode, async () => {
@ -250,7 +257,7 @@ watch(() => socket.isConnected, async () => {
} }
}) })
const reloadContent = async (fromMounted = false) => { const reloadContent = async (fromMounted: boolean = false) => {
try { try {
isLoading.value = true isLoading.value = true
const response = await request('/api/tasks') const response = await request('/api/tasks')
@ -276,18 +283,16 @@ const reloadContent = async (fromMounted = false) => {
} }
} }
const resetForm = (closeForm = false) => { const resetForm = (closeForm: boolean = false) => {
task.value = {} task.value = {}
taskRef.value = null taskRef.value = ''
addInProgress.value = false addInProgress.value = false
if (closeForm) { if (closeForm) {
toggleForm.value = false toggleForm.value = false
} }
} }
const updateTasks = async items => { const updateTasks = async (items: Array<task_item>) => {
let data = {}
try { try {
addInProgress.value = true addInProgress.value = true
@ -299,24 +304,24 @@ const updateTasks = async items => {
body: JSON.stringify(items.map(item => cleanObject(toRaw(item), remove_keys))), body: JSON.stringify(items.map(item => cleanObject(toRaw(item), remove_keys))),
}) })
data = await response.json() const data: Array<task_item> | error_response = await response.json()
if (200 !== response.status) { if ("error" in data) {
toast.error(`Failed to update task. ${data.error}`); toast.error(`Failed to update tasks. ${data.error}`);
return false return false
} }
tasks.value = data tasks.value = data
resetForm(true) resetForm(true)
return true return true
} catch (e) { } catch (e: any) {
toast.error(`Failed to update task. ${data?.error ?? e.message}`); toast.error(`Failed to update tasks. ${e.message}`);
} finally { } finally {
addInProgress.value = false addInProgress.value = false
} }
} }
const deleteItem = async item => { const deleteItem = async (item: task_item) => {
if (true !== box.confirm(`Delete '${item.name}' task?`, true)) { if (true !== box.confirm(`Delete '${item.name}' task?`, true)) {
return return
} }
@ -338,7 +343,7 @@ const deleteItem = async item => {
toast.success('Task deleted.') toast.success('Task deleted.')
} }
const updateItem = async ({ reference, task }) => { const updateItem = async ({ reference, task }: { reference?: string, task: task_item }) => {
if (reference) { if (reference) {
// -- find the task index. // -- find the task index.
const index = tasks.value.findIndex((t) => t?.id === reference) const index = tasks.value.findIndex((t) => t?.id === reference)
@ -354,17 +359,17 @@ const updateItem = async ({ reference, task }) => {
return return
} }
toast.success('Task updated') toast.success('Task updated.')
resetForm(true) resetForm(true)
} }
const editItem = item => { const editItem = (item: task_item) => {
task.value = item task.value = item
taskRef.value = item.id taskRef.value = item.id
toggleForm.value = true toggleForm.value = true
} }
const calcPath = path => { const calcPath = (path: string) => {
const loc = config.app.download_path || '/downloads' const loc = config.app.download_path || '/downloads'
if (path) { if (path) {
@ -382,7 +387,7 @@ onMounted(async () => {
await reloadContent(true) await reloadContent(true)
}); });
const tryParse = expression => { const tryParse = (expression: string) => {
try { try {
return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow() return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow()
} catch (e) { } catch (e) {
@ -390,7 +395,7 @@ const tryParse = expression => {
} }
} }
const runNow = async item => { const runNow = async (item: task_item) => {
if (true !== box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`)) { if (true !== box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`)) {
return return
} }
@ -400,7 +405,7 @@ const runNow = async item => {
let data = { let data = {
url: item.url, url: item.url,
preset: item.preset, preset: item.preset,
} } as task_item
if (item.folder) { if (item.folder) {
data.folder = item.folder data.folder = item.folder
@ -422,9 +427,9 @@ const runNow = async item => {
}, 500) }, 500)
} }
onUnmounted(() => socket.off('status', statusHandler)) onBeforeUnmount(() => socket.off('status', statusHandler))
const statusHandler = async stream => { const statusHandler = async (stream: string) => {
const { status, msg } = JSON.parse(stream) const { status, msg } = JSON.parse(stream)
if ('error' === status) { if ('error' === status) {
@ -433,7 +438,7 @@ const statusHandler = async stream => {
} }
} }
const exportItem = async item => { const exportItem = async (item: task_item) => {
const info = JSON.parse(JSON.stringify(item)) const info = JSON.parse(JSON.stringify(item))
let data = { let data = {
@ -442,7 +447,7 @@ const exportItem = async item => {
preset: info.preset, preset: info.preset,
timer: info.timer, timer: info.timer,
folder: info.folder, folder: info.folder,
} } as exported_task
if (info.template) { if (info.template) {
data.template = info.template data.template = info.template
@ -452,8 +457,8 @@ const exportItem = async item => {
data.cli = info.cli data.cli = info.cli
} }
data['_type'] = 'task' data._type = 'task'
data['_version'] = '2.0' data._version = '2.0'
return copyText(base64UrlEncode(JSON.stringify(data))); return copyText(base64UrlEncode(JSON.stringify(data)));
} }