Merge pull request #491 from arabcoders/dev
Refactor: Use tab design for index page
This commit is contained in:
commit
83a416ec4c
13 changed files with 925 additions and 820 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -84,6 +84,7 @@
|
|||
"gibibytes",
|
||||
"gitea",
|
||||
"gitpython",
|
||||
"googledrive",
|
||||
"gpac",
|
||||
"hiddenimports",
|
||||
"hookspath",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"> - 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue