Merge pull request #491 from arabcoders/dev

Refactor: Use tab design for index page
This commit is contained in:
Abdulmohsen 2025-11-15 22:12:25 +03:00 committed by GitHub
commit 83a416ec4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 925 additions and 820 deletions

View file

@ -84,6 +84,7 @@
"gibibytes",
"gitea",
"gitpython",
"googledrive",
"gpac",
"hiddenimports",
"hookspath",

View file

@ -45,7 +45,7 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
depth_limit=config.download_path_depth - 1,
),
"history_count": queue.done.get_total_count(),
**queue.get()["queue"],
"queue": queue.get()["queue"],
},
title="Sending initial download data",
message=f"Sending initial download data to client '{sid}'.",

File diff suppressed because it is too large Load diff

View file

@ -88,20 +88,24 @@
</div>
<div class="columns is-multiline is-mobile" v-if="showAdvanced">
<div class="column is-3-tablet is-12-mobile">
<DLInput id="force_download" type="bool" label="Force download"
v-model="dlFields['--no-download-archive']" icon="fa-solid fa-download"
:disabled="!socket.isConnected || addInProgress" description="Ignore archive and re-download." />
<div class="column is-2-tablet is-12-mobile">
<DLInput id="force_download" type="bool" label="Force download" icon="fa-solid fa-download"
v-model="dlFields['--no-download-archive']" :disabled="!socket.isConnected || addInProgress"
description="Ignore archive." />
</div>
<div class="column is-3-tablet is-12-mobile">
<div class="column is-2-tablet is-12-mobile">
<DLInput id="auto_start" type="bool" label="Auto start" v-model="auto_start" icon="fa-solid fa-play"
:disabled="!socket.isConnected || addInProgress"
description="Whether to start the download automatically." />
:disabled="!socket.isConnected || addInProgress" description="Download automatically." />
</div>
<div class="column is-2-tablet is-12-mobile">
<DLInput id="no_cache" type="bool" label="Bypass cache" icon="fa-solid fa-broom"
v-model="dlFields['--no-continue']" :disabled="!socket.isConnected || addInProgress"
description="Remove temporary files." />
</div>
<div class="column is-6-tablet is-12-mobile">
<DLInput id="output_template" type="string" label="Output template" v-model="form.template"
icon="fa-solid fa-file" :disabled="!socket.isConnected || addInProgress"
:placeholder="getDefault('template', config.app.output_template || '%(title)s.%(ext)s')">
@ -284,7 +288,7 @@ const showFields = ref<boolean>(false)
const showOptions = ref<boolean>(false)
const showTestResults = ref<boolean>(false)
const testResultsData = ref<any>(null)
const dlFieldsExtra = ['--no-download-archive']
const dlFieldsExtra = ['--no-download-archive', '--no-continue']
const ytDlpOpt = ref<AutoCompleteOptions>([])
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null)

View file

@ -1,268 +1,112 @@
<template>
<h1 class="mt-3 is-size-3 is-clickable is-unselectable" @click="showQueue = !showQueue" v-if="hasQueuedItems">
<span class="icon-text title is-4">
<span class="icon">
<i class="fas" :class="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</span>
<span>Queue <span v-if="hasQueuedItems">({{ filteredItems.length }})</span>
<span v-if="selectedElms.length > 0">&nbsp;- Selected: {{ selectedElms.length }}</span>
</span>
</span>
</h1>
<div v-if="showQueue">
<div class="columns is-multiline is-mobile has-text-centered" v-if="filteredItems.length > 0">
<div class="column is-half-mobile" v-if="'grid' === display_style">
<button type="button" class="button is-fullwidth is-ghost" @click="masterSelectAll = !masterSelectAll">
<span class="icon-text is-block">
<span class="icon">
<i class="fas" :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
</span>
<span v-if="!masterSelectAll">Select All</span>
<span v-else>Unselect All</span>
<div class="columns is-multiline is-mobile has-text-centered" v-if="filteredItems.length > 0">
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-ghost" @click="masterSelectAll = !masterSelectAll">
<span v-if="selectedElms.length > 0" class="mr-2 tag is-danger">
{{ selectedElms.length }}
</span>
<span class="icon-text is-block">
<span class="icon">
<i class="fas" :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasManualStart">
<button type="button" class="button is-fullwidth is-success" :disabled="!hasSelected" @click="startItems">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasPausable">
<button type="button" class="button is-fullwidth is-warning is-background-warning-85" :disabled="!hasSelected"
@click="pauseSelected">
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span>
</button>
</div>
<div class="column is-half-mobile" v-if="('grid' === display_style || hasSelected)">
<button type="button" class="button is-fullwidth is-warning" :disabled="!hasSelected" @click="cancelSelected">
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span>
</button>
</div>
<span v-if="!masterSelectAll">Select All</span>
<span v-else> Unselect All</span>
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasManualStart">
<button type="button" class="button is-fullwidth is-success" :disabled="!hasSelected" @click="startItems">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasPausable">
<button type="button" class="button is-fullwidth is-warning is-background-warning-85" :disabled="!hasSelected"
@click="pauseSelected">
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span>
</button>
</div>
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-warning" :disabled="!hasSelected" @click="cancelSelected">
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span>
</button>
</div>
</div>
<div class="columns is-multiline" v-if="'list' === display_style">
<div class="column is-12" v-if="filteredItems.length > 0">
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 1300px; table-layout: fixed;">
<thead>
<tr class="has-text-centered is-unselectable">
<th width="5%" v-tooltip="masterSelectAll ? 'Unselect all' : 'Select all'">
<a href="#" @click.prevent="masterSelectAll = !masterSelectAll">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-regular"
:class="{ 'fa-square-check': !masterSelectAll, 'fa-square': masterSelectAll }" />
</span>
<div class="columns is-multiline" v-if="'list' === display_style">
<div class="column is-12" v-if="filteredItems.length > 0">
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 1300px; table-layout: fixed;">
<thead>
<tr class="has-text-centered is-unselectable">
<th width="5%" v-tooltip="masterSelectAll ? 'Unselect all' : 'Select all'">
<a href="#" @click.prevent="masterSelectAll = !masterSelectAll">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-regular"
:class="{ 'fa-square-check': !masterSelectAll, 'fa-square': masterSelectAll }" />
</span>
</a>
</th>
<th width="40%">Video Title</th>
<th width="15%">Status</th>
<th width="20%">Progress</th>
<th width="15%">Created</th>
<th width="10%">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredItems" :key="item._id">
<td class="has-text-centered is-vcentered">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms"
:id="'checkbox-' + item._id" :value="item._id">
</label>
</td>
<td class="is-text-overflow is-vcentered">
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || item.extras?.duration">
<span class="tag" v-if="item.downloaded_bytes">{{ formatBytes(item.downloaded_bytes) }}</span>
<span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
</div>
<div v-if="showThumbnails && item.extras?.thumbnail">
<FloatingImage
:image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
:title="item.title">
<div class="is-text-overflow">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
</FloatingImage>
</div>
<template v-else>
<div class="is-text-overflow" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
</template>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span class="icon" :class="setIconColor(item)">
<i class="fas fa-solid" :class="setIcon(item)" />
</span>
<span v-text="setStatus(item)" />
</td>
<td>
<div class="progress-bar is-unselectable">
<div class="progress-percentage">{{ updateProgress(item) }}</div>
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
</div>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"
:data-datetime="item.datetime" v-rtime="item.datetime" />
</td>
<td class="is-vcentered is-items-center">
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
<template v-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string">
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item has-text-warning" @click="confirmCancel(item);">
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel Download</span>
</NuxtLink>
<template v-if="!item.auto_start && !item.status">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-success" @click="startItem(item)">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start Download</span>
</NuxtLink>
</template>
<template v-if="item.auto_start && !item.status">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-warning" @click="pauseItem(item)">
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause Download</span>
</NuxtLink>
</template>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
<span>Local Information</span>
</NuxtLink>
</Dropdown>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="columns is-multiline" v-else>
<LateLoader :unrender="true" :min-height="showThumbnails ? 475 : 265" class="column is-6"
v-for="item in filteredItems" :key="item._id">
<div class="card">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
<div class="card-header-icon">
<div class="field is-grouped">
<div class="control">
</a>
</th>
<th width="40%">Video Title</th>
<th width="15%">Status</th>
<th width="20%">Progress</th>
<th width="15%">Created</th>
<th width="10%">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredItems" :key="item._id">
<td class="has-text-centered is-vcentered">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id">
</label>
</td>
<td class="is-text-overflow is-vcentered">
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || item.extras?.duration">
<span class="tag" v-if="item.downloaded_bytes">{{ formatBytes(item.downloaded_bytes) }}</span>
<span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
</div>
<div class="control">
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }" /></span>
</button>
<div v-if="showThumbnails && item.extras?.thumbnail">
<FloatingImage
:image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
:title="item.title">
<div class="is-text-overflow">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
</FloatingImage>
</div>
<div class="control">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms"
:id="'checkbox-' + item._id" :value="item._id">
</label>
</div>
</div>
</div>
</header>
<div v-if="showThumbnails" class="card-image">
<figure :class="['image', thumbnail_ratio]">
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay">
<div class="play-icon embed-icon"></div>
<img @load="(e: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<img @load="(e: Event) => pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
<img v-else src="/images/placeholder.png" />
</template>
</figure>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<div class="progress-bar is-unselectable">
<div class="progress-percentage">{{ updateProgress(item) }}</div>
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
</div>
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<template v-else>
<div class="is-text-overflow" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
</template>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span class="icon" :class="setIconColor(item)">
<i class="fas fa-solid" :class="setIcon(item)" />
</span>
<span v-text="setStatus(item)" />
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span v-tooltip="`Preset: ${item.preset}`" class="user-hint">{{ item.preset }}</span>
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
</td>
<td>
<div class="progress-bar is-unselectable">
<div class="progress-percentage">{{ updateProgress(item) }}</div>
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
</div>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime"
v-rtime="item.datetime" />
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"
v-if="item.downloaded_bytes" v-tooltip="`Saving to: ${makePath(item)}`">
{{ formatBytes(item.downloaded_bytes) }}
</div>
</div>
<div class="columns is-multiline is-mobile">
<div class="column is-half-mobile">
<button class="button is-warning is-fullwidth" @click="confirmCancel(item);">
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span>
</button>
</div>
<div class="column is-half-mobile" v-if="!item.auto_start && !item.status">
<button class="button is-success is-fullwidth" @click="startItem(item)">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start</span>
</button>
</div>
<div class="column is-half-mobile" v-if="item.auto_start && !item.status">
<button class="button is-warning is-background-warning-85 is-fullwidth" @click="pauseItem(item)">
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span>
</button>
</div>
<div class="column is-half-mobile">
<Dropdown icons="fa-solid fa-cogs" label="Actions">
</td>
<td class="is-vcentered is-items-center">
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
<template v-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string">
@ -272,6 +116,29 @@
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item has-text-warning" @click="confirmCancel(item);">
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel Download</span>
</NuxtLink>
<template v-if="!item.auto_start && !item.status">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-success" @click="startItem(item)">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start Download</span>
</NuxtLink>
</template>
<template v-if="item.auto_start && !item.status">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-warning" @click="pauseItem(item)">
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause Download</span>
</NuxtLink>
</template>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
@ -282,30 +149,156 @@
<span>Local Information</span>
</NuxtLink>
</Dropdown>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="columns is-multiline" v-else>
<LateLoader :unrender="true" :min-height="showThumbnails ? 475 : 265" class="column is-6"
v-for="item in filteredItems" :key="item._id">
<div class="card">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
<div class="card-header-icon">
<div class="field is-grouped">
<div class="control">
<span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
</div>
<div class="control">
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }" /></span>
</button>
</div>
<div class="control">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id">
</label>
</div>
</div>
</div>
</header>
<div v-if="showThumbnails" class="card-image">
<figure :class="['image', thumbnail_ratio]">
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay">
<div class="play-icon embed-icon"></div>
<img @load="(e: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<img @load="(e: Event) => pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
<img v-else src="/images/placeholder.png" />
</template>
</figure>
</div>
</LateLoader>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<div class="progress-bar is-unselectable">
<div class="progress-percentage">{{ updateProgress(item) }}</div>
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
</div>
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon" :class="setIconColor(item)">
<i class="fas fa-solid" :class="setIcon(item)" />
</span>
<span v-text="setStatus(item)" />
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span v-tooltip="`Preset: ${item.preset}`" class="user-hint">{{ item.preset }}</span>
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime"
v-rtime="item.datetime" />
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"
v-if="item.downloaded_bytes" v-tooltip="`Saving to: ${makePath(item)}`">
{{ formatBytes(item.downloaded_bytes) }}
</div>
<div class="columns is-multiline" v-if="hasQueuedItems && filteredItems.length < 1 && query">
<div class="column is-12">
<Message message_class="has-background-warning-90 has-text-dark" title="No results for queued items"
icon="fas fa-search" :useClose="true" @close="() => emitter('clear_search')" v-if="query">
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
</Message>
</div>
</div>
</div>
<div class="columns is-multiline is-mobile">
<div class="column is-half-mobile">
<button class="button is-warning is-fullwidth" @click="confirmCancel(item);">
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span>
</button>
</div>
<div class="column is-half-mobile" v-if="!item.auto_start && !item.status">
<button class="button is-success is-fullwidth" @click="startItem(item)">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start</span>
</button>
</div>
<div class="column is-half-mobile" v-if="item.auto_start && !item.status">
<button class="button is-warning is-background-warning-85 is-fullwidth" @click="pauseItem(item)">
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span>
</button>
</div>
<div class="column is-half-mobile">
<Dropdown icons="fa-solid fa-cogs" label="Actions">
<template v-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url) as string">
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<div class="modal is-active" v-if="embed_url">
<div class="modal-background" @click="embed_url = ''"></div>
<div class="modal-content is-unbounded-model">
<EmbedPlayer :url="embed_url" @closeModel="embed_url = ''" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
<span>Local Information</span>
</NuxtLink>
</Dropdown>
</div>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>
</LateLoader>
</div>
<div class="columns is-multiline" v-if="filteredItems.length < 1">
<div class="column is-12">
<Message message_class="is-warning" title="Filter results" :newStyle="true" icon="fas fa-search" :useClose="true"
@close="() => emitter('clear_search')" v-if="query">
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
</Message>
<Message message_class="is-info" title="No items" icon="fas fa-clock" :useClose="false" :newStyle="true"
v-else>
<p>The download queue is empty.</p>
</Message>
</div>
</div>
<div class="modal is-active" v-if="embed_url">
<div class="modal-background" @click="embed_url = ''"></div>
<div class="modal-content is-unbounded-model">
<EmbedPlayer :url="embed_url" @closeModel="embed_url = ''" />
</div>
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>
</div>
</template>
<script setup lang="ts">
@ -331,7 +324,6 @@ const socket = useSocketStore()
const box = useConfirm()
const toast = useNotification()
const showQueue = useStorage('showQueue', true)
const hideThumbnail = useStorage('hideThumbnailQueue', false)
const display_style = useStorage('display_style', 'grid')
const bg_enable = useStorage('random_bg', true)
@ -363,7 +355,6 @@ const filteredItems = computed<StoreItem[]>(() => {
})
const hasSelected = computed(() => 0 < selectedElms.value.length)
const hasQueuedItems = computed(() => 0 < stateStore.count('queue'))
const hasManualStart = computed(() => {
if (0 > stateStore.count('queue')) {
return false

View file

@ -4,7 +4,7 @@
<section class="download-form box">
<form class="download-form__body" autocomplete="off" @submit.prevent="addDownload">
<label class="label" for="download-url">
What you would like to download?
{{ greetingMessage }}
<span class="is-pulled-right">
<span class="icon is-pointer" :class="connectionStatusColor" @click="$emit('show_settings')"
v-tooltip="'WebUI Settings'">
@ -175,6 +175,16 @@
</article>
</div>
</TransitionGroup>
<div v-if="paginationInfo.isLoaded && paginationInfo.page < paginationInfo.total_pages" ref="loadMoreTrigger"
class="columns is-centered mt-4">
<div class="column is-narrow">
<div v-if="paginationInfo.isLoading" class="has-text-centered">
<span class="icon is-large has-text-info"><i class="fas fa-spinner fa-pulse fa-2x" /></span>
<p class="is-size-7 has-text-grey mt-2">Loading more items...</p>
</div>
</div>
</div>
</section>
</Transition>
@ -199,9 +209,9 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useStorage } from '@vueuse/core'
import { useStorage, useIntersectionObserver } from '@vueuse/core'
import type { item_request } from '~/types/item'
import type { ItemStatus, StoreItem } from '~/types/store'
import { useNotification } from '~/composables/useNotification'
@ -224,6 +234,7 @@ const { queue, history } = storeToRefs(stateStore)
const embedUrl = ref<string>('')
const videoItem = ref<StoreItem | null>(null)
const loadMoreTrigger = ref<HTMLElement | null>(null)
const formUrl = ref<string>('')
const formPreset = ref<{ preset: string }>({ preset: app.value.default_preset || '' })
@ -236,6 +247,7 @@ const sortByNewest = (items: StoreItem[]): StoreItem[] => items.slice().sort((a,
const queueItems = computed<StoreItem[]>(() => sortByNewest(Object.values(queue.value ?? {})))
const historyEntries = computed<StoreItem[]>(() => sortByNewest(Object.values(history.value ?? {})))
const paginationInfo = computed(() => stateStore.getPagination())
const downloadingStatuses: ReadonlySet<ItemStatus | null> = new Set(['downloading', 'postprocessing', 'preparing'])
@ -255,6 +267,24 @@ const displayItems = computed<DisplayEntry[]>(() => [
const hasActiveQueue = computed<boolean>(() => queueItems.value.length > 0)
const hasAnyItems = computed<boolean>(() => hasActiveQueue.value || historyEntries.value.length > 0)
const shouldCenterForm = computed<boolean>(() => 0 === queueItems.value.length && 0 === historyEntries.value.length)
const DEFAULT_PAGE_SIZE = 12
const greetingMessage = computed<string>(() => {
const hour = new Date().getHours()
let greeting = ''
if (hour >= 5 && hour < 12) {
greeting = 'Good morning'
} else if (hour >= 12 && hour < 17) {
greeting = 'Good afternoon'
} else if (hour >= 17 && hour < 21) {
greeting = 'Good evening'
} else {
greeting = 'Hello'
}
return `${greeting}, what would you like to download?`
})
const addDownload = async (): Promise<void> => {
const url = formUrl.value.trim()
@ -617,6 +647,59 @@ const connectionStatusColor = computed(() => {
}
})
// Load history via API on mount
onMounted(async () => {
if (socketStore.isConnected && !paginationInfo.value.isLoaded) {
try {
await stateStore.loadPaginated('history', 1, DEFAULT_PAGE_SIZE, 'DESC')
} catch (error) {
console.error('Failed to load history on mount:', error)
}
}
if (window?.location && '/' !== window.location.pathname) {
window.history.replaceState({}, '', '/')
}
})
// Reload history when socket reconnects
watch(() => socketStore.isConnected, async (connected) => {
if (connected && !paginationInfo.value.isLoaded) {
try {
await stateStore.loadPaginated('history', 1, DEFAULT_PAGE_SIZE, 'DESC')
} catch (error) {
console.error('Failed to load history after socket connection:', error)
}
}
})
// Function to load more history items
const loadMoreHistory = async (): Promise<void> => {
if (paginationInfo.value.isLoading || paginationInfo.value.page >= paginationInfo.value.total_pages) {
return
}
try {
await stateStore.loadPaginated('history', paginationInfo.value.page + 1, DEFAULT_PAGE_SIZE, 'DESC', true)
} catch (error) {
console.error('Failed to load more history:', error)
toast.error('Failed to load more history')
}
}
// Setup intersection observer for infinite scroll
useIntersectionObserver(
loadMoreTrigger,
([entry]) => {
if (entry?.isIntersecting && !paginationInfo.value.isLoading && paginationInfo.value.page < paginationInfo.value.total_pages) {
loadMoreHistory()
}
},
{
threshold: 0.5,
}
)
</script>
<style scoped>

View file

@ -20,7 +20,8 @@
<div class="card-content">
<div class="columns is-multiline">
<div class="column is-12">
<settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)"
@close="closeSettings()" />
</div>
</div>
</div>
@ -162,12 +163,16 @@
connection is functional.
</span>
</p>
<p v-if="socket.error" class="has-text-danger">
<span class="icon-text">
<span class="icon"><i class="fas fa-triangle-exclamation" /></span>
{{ socket.error }}. Check the developer console for more information.
</span>
</p>
<template v-if="socket.error">
<hr>
<p class="has-text-danger">
<span class="icon-text">
<span class="icon"><i class="fas fa-triangle-exclamation" /></span>
<span class="tag is-danger">{{ socket.error_count }}</span>
{{ socket.error }}. Check the developer console for more information.
</span>
</p>
</template>
</Message>
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
<ClientOnly>

View file

@ -38,7 +38,7 @@
<p class="control">
<button class="button is-primary has-tooltip-bottom" @click="config.showForm = !config.showForm">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span v-if="!isMobile">New Download</span>
<span v-if="!isMobile">Add</span>
</button>
</p>
@ -62,17 +62,45 @@
</div>
</div>
<NewDownload v-if="config.showForm"
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:item="item_form" @clear_form="item_form = {}" />
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
@clear_search="query = ''" />
<History @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
@add_new="(item: Partial<StoreItem>) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
<NewDownload v-if="config.showForm" :item="item_form" @clear_form="item_form = {}"
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" />
<div class="tabs is-boxed is-medium">
<ul>
<li :class="{ 'is-active': activeTab === 'queue' }">
<a @click="setActiveTab('queue')">
<span class="icon is-small"><i class="fas fa-clock" aria-hidden="true"></i></span>
<span>Queue</span>
<span class="tag is-info is-rounded is-bold ml-2">{{ queueCount }}</span>
</a>
</li>
<li :class="{ 'is-active': activeTab === 'history' }">
<a @click="setActiveTab('history')">
<span class="icon is-small"><i class="fas fa-history" aria-hidden="true"></i></span>
<span>History</span>
<span class="tag is-primary is-rounded is-bold ml-2">{{ historyCount }}</span>
</a>
</li>
</ul>
</div>
<div class="tab-content">
<div v-show="'queue' === activeTab">
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:thumbnails="show_thumbnail" :query="query"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
</div>
<div v-show="'history' === activeTab">
<History @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
@add_new="(item: Partial<StoreItem>) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
</div>
</div>
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :cli="info_view.cli"
: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" />
@ -87,6 +115,9 @@ import type { StoreItem } from '~/types/store'
const config = useConfigStore()
const stateStore = useStateStore()
const socket = useSocketStore()
const route = useRoute()
const router = useRouter()
const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
const display_style = useStorage<string>('display_style', 'grid')
@ -110,6 +141,35 @@ const dialog_confirm = ref({
options: [],
})
// Tab management with URL state
type TabType = 'queue' | 'history'
const activeTab = ref<TabType>('queue')
const getInitialTab = (): TabType => {
const tabParam = route.query.tab as string
return (tabParam === 'queue' || tabParam === 'history') ? tabParam : 'queue'
}
const setActiveTab = async (tab: TabType): Promise<void> => {
activeTab.value = tab
await router.push({ query: { ...route.query, tab }, replace: true })
}
watch(() => route.query.tab, (newTab) => {
if (!['queue', 'history'].includes(newTab as string)) {
return
}
activeTab.value = newTab as TabType
})
onMounted(() => {
activeTab.value = getInitialTab()
useHead({ title: getTitle() })
})
const queueCount = computed(() => stateStore.count('queue'))
const historyCount = computed(() => stateStore.count('history'))
watch(toggleFilter, () => {
if (!toggleFilter.value) {
@ -124,8 +184,6 @@ const getTitle = (): string => {
return `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers}:${config.app.max_workers_per_extractor} | ${Object.keys(stateStore.history).length || 0} )`
}
onMounted(() => useHead({ title: getTitle() }))
watch(() => stateStore.history, () => {
if (!config.app.ui_update_title) {
return

View file

@ -32,7 +32,6 @@
</div>
</div>
<span class="field title is-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-palette" /></span>
@ -300,7 +299,7 @@ import { useNotification } from '~/composables/useNotification'
import type { notificationTarget } from '~/composables/useNotification'
defineProps<{ isLoading: boolean }>()
defineEmits<{ (e: 'reload_bg'): void }>()
const emitter = defineEmits<{ (e: 'reload_bg' | 'close'): void }>()
const config = useConfigStore()
const notification = useNotification()
@ -335,4 +334,6 @@ const onNotificationTargetChange = async (): Promise<void> => {
}
}
}
watch(simpleMode, async () => emitter('close'))
</script>

View file

@ -3,7 +3,7 @@ import type { ConfigState } from '~/types/config';
export const useConfigStore = defineStore('config', () => {
const state = reactive<ConfigState>({
showForm: useStorage('showForm', false),
showForm: useStorage('showForm', true),
app: {
download_path: '/downloads',
remove_files: false,

View file

@ -14,6 +14,7 @@ export const useSocketStore = defineStore('socket', () => {
const isConnected = ref<boolean>(false)
const connectionStatus = ref<connectionStatus>('disconnected')
const error = ref<string | null>(null)
const error_count = ref<number>(0)
const emit = (event: string, data?: any): any => socket.value?.emit(event, data)
const on = (event: string | string[], callback: (...args: any[]) => void, withEvent: boolean = false) => {
@ -52,7 +53,7 @@ export const useSocketStore = defineStore('socket', () => {
const connect = () => {
const opts = {
transports: ['polling', 'websocket'],
transports: ['websocket', 'polling'],
withCredentials: true,
reconnection: true,
reconnectionAttempts: 50,
@ -80,6 +81,7 @@ export const useSocketStore = defineStore('socket', () => {
return;
}
error.value = `Connection error: ${e.type || 'Unknown'}: ${e.message || 'Unknown error'}`;
error_count.value += 1
});
@ -87,6 +89,7 @@ export const useSocketStore = defineStore('socket', () => {
isConnected.value = true
connectionStatus.value = 'connected';
error.value = null;
error_count.value = 0
});
on('disconnect', () => {
@ -240,5 +243,6 @@ export const useSocketStore = defineStore('socket', () => {
getSessionId,
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
error: readonly(error) as Readonly<Ref<string | null>>,
error_count: readonly(error_count) as Readonly<Ref<number>>,
};
});

View file

@ -37,6 +37,9 @@ export const useStateStore = defineStore('state', () => {
})
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
if ('history' === type && state.pagination.total > 0) {
state.pagination.total += 1
}
state[type][key] = value
}
@ -45,6 +48,10 @@ export const useStateStore = defineStore('state', () => {
}
const remove = (type: StateType, key: KeyType): void => {
if ('history' === type && state.pagination.total > 0) {
state.pagination.total -= 1
}
if (state[type][key]) {
const { [key]: _, ...rest } = state[type]
state[type] = rest
@ -77,11 +84,11 @@ export const useStateStore = defineStore('state', () => {
}
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
if (state[fromType][key]) {
state[toType][key] = state[fromType][key]
const { [key]: _, ...rest } = state[fromType]
state[fromType] = rest
if (true === has(fromType, key)) {
remove(fromType, key)
}
add(toType, key, get(fromType, key, {} as StoreItem) as StoreItem)
}
const count = (type: StateType): number => {
@ -91,7 +98,7 @@ export const useStateStore = defineStore('state', () => {
return Object.keys(state[type]).length
}
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC'): Promise<void> => {
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC', append: boolean = false): Promise<void> => {
if ('history' !== type) {
throw new Error('Pagination is only supported for history type');
}
@ -110,7 +117,8 @@ export const useStateStore = defineStore('state', () => {
for (const item of data.items || []) {
items[item._id] = item
}
state[type] = items
state[type] = append ? { ...state[type], ...items } : items
}
} catch (error) {
console.error(`Failed to load ${type} page ${page}:`, error)
@ -118,7 +126,7 @@ export const useStateStore = defineStore('state', () => {
}
}
const loadNextPage = async (type: StateType): Promise<void> => {
const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
if ('history' !== type) {
throw new Error('Pagination is only supported for history type');
}
@ -127,7 +135,7 @@ export const useStateStore = defineStore('state', () => {
return
}
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page)
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page, 'DESC', append)
}
const loadPreviousPage = async (type: StateType): Promise<void> => {

View file

@ -70,6 +70,11 @@ const sources: EmbedSource[] = [
url: "https://player.bilibili.com/player.html?aid=12569853&bvid={id}&cid=20681553&p=1&high_quality=1",
regex: /^https?:\/\/(?:www\.)?bilibili\.com\/video\/(?<id>BV[0-9A-Za-z]+)\/?/i,
},
{
name: "googledrive",
url: "https://drive.google.com/file/d/{id}/preview",
regex: /https?:\/\/(?:www\.)?drive\.google\.com\/file\/d\/(?<id>[^/?#&]+)/,
},
]
const isEmbedable = (url: string): boolean => sources.some(source => source.regex.test(url))