Feat: Paginate history items to improve performance
This commit is contained in:
parent
1107567f86
commit
739d8e702f
13 changed files with 448 additions and 66 deletions
27
API.md
27
API.md
|
|
@ -81,6 +81,7 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [Connection Events](#connection-events)
|
||||
- [`connect` (Built-in)](#connect-built-in)
|
||||
- [`disconnect` (Built-in)](#disconnect-built-in)
|
||||
- [`configuration` (Server → Client)](#configuration-server--client)
|
||||
- [`connected` (Server → Client)](#connected-server--client)
|
||||
- [Subscription Events](#subscription-events)
|
||||
- [`subscribe` (Client → Server)](#subscribe-client--server)
|
||||
|
|
@ -450,7 +451,7 @@ or an error:
|
|||
- `queue`: Returns only queue items (with pagination)
|
||||
- `done`: Returns only history items (with pagination)
|
||||
- `page` (optional): Page number (1-indexed). Default: `1`. Only used when `type != all`
|
||||
- `per_page` (optional): Items per page. Default: `50`, Max: `200`. Only used when `type != all`
|
||||
- `per_page` (optional): Items per page. Default: `config.default_pagination`, Max: `200`. Only used when `type != all`
|
||||
- `order` (optional): Sort order. Default: `DESC`. Only used when `type != all`
|
||||
- `DESC`: Newest items first (descending by creation date)
|
||||
- `ASC`: Oldest items first (ascending by creation date)
|
||||
|
|
@ -1733,25 +1734,35 @@ Fired when WebSocket connection is closed. No data payload.
|
|||
socket.on('disconnect', (reason: string) => console.log('WebSocket disconnected:', reason));
|
||||
```
|
||||
|
||||
##### `connected` (Server → Client)
|
||||
Initial connection event with full application state.
|
||||
##### `configuration` (Server → Client)
|
||||
Sends the current application configuration.
|
||||
|
||||
**Data Fields**:
|
||||
- `config`: Global configuration object
|
||||
- `queue`: Current download queue (array of items)
|
||||
- `done`: Download history (array of completed items)
|
||||
- `tasks`: Scheduled tasks
|
||||
- `presets`: Available download presets
|
||||
- `dl_fields`: Available download fields
|
||||
- `folders`: Directory structure for downloads
|
||||
- `paused`: Queue pause status (boolean)
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
socket.on('connected', (data: string) => {
|
||||
const json = JSON.parse(data);
|
||||
console.log('Current configuration:', json.data.config);
|
||||
});
|
||||
```
|
||||
|
||||
##### `connected` (Server → Client)
|
||||
When a client connects, this events sends the folder and current queue.
|
||||
|
||||
**Data Fields**:
|
||||
- `queue`: Current download queue (array of items)
|
||||
- `folders`: Directory structure for downloads
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
socket.on('connected', (data: string) => {
|
||||
const json = JSON.parse(data);
|
||||
const queueItems = json.data.queue || {};
|
||||
const historyItems = json.data.done || {};
|
||||
console.log('Connected with', Object.keys(queueItems).length, 'queued downloads');
|
||||
});
|
||||
```
|
||||
|
|
|
|||
1
FAQ.md
1
FAQ.md
|
|
@ -48,6 +48,7 @@ or the `environment:` section in `compose.yaml` file.
|
|||
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
|
||||
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
|
||||
| YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` |
|
||||
| YTP_DEFAULT_PAGINATION | The default number of items per page for history. | `50` |
|
||||
|
||||
> [!NOTE]
|
||||
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
|
||||
|
|
|
|||
|
|
@ -195,6 +195,9 @@ class Config(metaclass=Singleton):
|
|||
auto_clear_history_days: int = 0
|
||||
"""Number of days after which completed download history is automatically cleared. 0 to disable."""
|
||||
|
||||
default_pagination: int = 50
|
||||
"""The default number of items per page for pagination."""
|
||||
|
||||
pictures_backends: list[str] = [
|
||||
"https://unsplash.it/1920/1080?random",
|
||||
"https://picsum.photos/1920/1080",
|
||||
|
|
@ -234,6 +237,7 @@ class Config(metaclass=Singleton):
|
|||
"download_path_depth",
|
||||
"download_info_expires",
|
||||
"auto_clear_history_days",
|
||||
"default_pagination",
|
||||
)
|
||||
"The variables that are integers."
|
||||
|
||||
|
|
@ -281,6 +285,7 @@ class Config(metaclass=Singleton):
|
|||
"app_commit_sha",
|
||||
"app_build_date",
|
||||
"app_branch",
|
||||
"default_pagination",
|
||||
)
|
||||
"The variables that are relevant to the frontend."
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
|
|||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.Download import Download
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.encoder import Encoder
|
||||
|
|
@ -20,7 +21,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
@route("GET", r"api/history/", "items_list")
|
||||
async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, config: Config) -> Response:
|
||||
"""
|
||||
Get the history with optional pagination support.
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder) -
|
|||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
|
@ -63,7 +65,7 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder) -
|
|||
|
||||
try:
|
||||
page = int(request.query.get("page", 1))
|
||||
per_page = int(request.query.get("per_page", 50))
|
||||
per_page = int(request.query.get("per_page", config.default_pagination))
|
||||
order = request.query.get("order", "DESC").upper()
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
|
|
@ -74,7 +76,7 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder) -
|
|||
if page < 1:
|
||||
return web.json_response(data={"error": "page must be >= 1."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if per_page < 1 or per_page > 200:
|
||||
if per_page < 1 or per_page > 1000:
|
||||
return web.json_response(
|
||||
data={"error": "per_page must be between 1 and 1000."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
|
|||
base=Path(config.download_path),
|
||||
depth_limit=config.download_path_depth - 1,
|
||||
),
|
||||
**queue.get(),
|
||||
"history_count": queue.done.get_total_count(),
|
||||
**queue.get()["queue"],
|
||||
},
|
||||
title="Sending initial download data",
|
||||
message=f"Sending initial download data to client '{sid}'.",
|
||||
|
|
|
|||
|
|
@ -2,19 +2,26 @@
|
|||
<vTooltip @show="loadContent" @hide="stopTimer">
|
||||
<slot />
|
||||
<template #popper>
|
||||
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
|
||||
<div v-else>
|
||||
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
|
||||
<div class="is-block" style="word-break: all;" v-if="props.title">
|
||||
<span style="font-size: 120%;">{{ props.title }}</span>
|
||||
<template v-if="error_msg">
|
||||
<Message message_class="is-danger" :newStyle="true">
|
||||
{{ error_msg }}
|
||||
</Message>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
|
||||
<div v-else>
|
||||
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
|
||||
<div class="is-block" style="word-break: all;" v-if="props.title">
|
||||
<span style="font-size: 120%;">{{ props.title }}</span>
|
||||
</div>
|
||||
<figure :class="['image', thumbnail_ratio, 'is-hidden-mobile']">
|
||||
<img @load="e => pImg(e)" :src="url" :alt="props.title" @error="clearCache"
|
||||
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
|
||||
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
|
||||
</figure>
|
||||
</div>
|
||||
<figure :class="['image', thumbnail_ratio, 'is-hidden-mobile']">
|
||||
<img @load="e => pImg(e)" :src="url" :alt="props.title" @error="clearCache"
|
||||
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
|
||||
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</vTooltip>
|
||||
</template>
|
||||
|
|
@ -36,6 +43,7 @@ const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'i
|
|||
const url = ref<string | null>(null)
|
||||
const error = ref(false)
|
||||
const isPreloading = ref(false)
|
||||
const error_msg = ref<string | null>(null)
|
||||
|
||||
const loadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const cancelRequest = new AbortController()
|
||||
|
|
@ -54,7 +62,7 @@ const defaultLoader = async (): Promise<void> => {
|
|||
const response = await request(props.image, { signal: cancelRequest.signal })
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(`ImageView Request error. ${response.status}: ${response.statusText}`)
|
||||
error_msg.value = `ImageView Request error. ${response.status}: ${response.statusText}`
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
<i :class="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
|
||||
</span>
|
||||
<span>
|
||||
History <span v-if="hasItems">({{ stateStore.count('history') }})</span>
|
||||
History
|
||||
<span v-if="stateStore.count('history')">({{ stateStore.count('history') }})</span>
|
||||
<span v-if="selectedElms.length > 0"> - Selected: {{ selectedElms.length }}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -77,6 +78,34 @@
|
|||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-1-tablet">
|
||||
<button type="button" class="button is-info is-fullwidth" @click="() => stateStore.reloadCurrentPage('history')"
|
||||
:disabled="paginationInfo.isLoading" :class="{ 'is-loading': paginationInfo.isLoading }">
|
||||
<span class="icon-text is-block">
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="paginationInfo.isLoading && !hasItems">
|
||||
<div class="column is-12">
|
||||
<div class="message is-info">
|
||||
<div class="message-body">
|
||||
<span class="icon-text">
|
||||
<span class="icon"> <i class="fa-solid fa-spinner fa-spin fa-2x" /> </span>
|
||||
<span class="ml-3">Loading history...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="paginationInfo.isLoaded && paginationInfo.total_pages > 1">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<Pager :page="paginationInfo.page" :last_page="paginationInfo.total_pages" :isLoading="paginationInfo.isLoading"
|
||||
@navigate="navigateToPage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="'list' === display_style">
|
||||
|
|
@ -410,19 +439,30 @@
|
|||
</LateLoader>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="!hasItems">
|
||||
<div class="columns is-multiline" v-if="!hasItems && !paginationInfo.isLoading">
|
||||
<div class="column is-12">
|
||||
<Message message_class="has-background-warning-90 has-text-dark" title="No results for downloaded items."
|
||||
icon="fas fa-search" :useClose="true" @close="() => emitter('clear_search')" v-if="query">
|
||||
<Message message_class="is-warning" title="Filter items" icon="fas fa-search" :useClose="true"
|
||||
@close="() => emitter('clear_search')" v-if="query" :newStyle="true">
|
||||
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
|
||||
</Message>
|
||||
<Message message_class="has-background-success-90 has-text-dark" title="No records in history."
|
||||
icon="fas fa-circle-check" v-else-if="socket.isConnected" />
|
||||
<Message message_class="is-warning" title="Page is empty." icon="fas fa-exclamation-triangle"
|
||||
v-else-if="socket.isConnected && paginationInfo.total" :new-style="true">
|
||||
<span class="is-block">The current page has no items. Try navigating to a different page.</span>
|
||||
</Message>
|
||||
<Message message_class="is-success" title="No records in history."
|
||||
icon="fas fa-circle-check" v-else-if="socket.isConnected" :new-style="true" />
|
||||
<Message message_class="has-background-info-90 has-text-dark" title="Connecting.." icon="fas fa-spinner fa-spin"
|
||||
v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="paginationInfo.isLoaded && paginationInfo.total_pages > 1">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<Pager :page="paginationInfo.page" :last_page="paginationInfo.total_pages" :isLoading="paginationInfo.isLoading"
|
||||
@navigate="navigateToPage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal is-active" v-if="video_item">
|
||||
<div class="modal-background" @click="closeVideo"></div>
|
||||
<div class="modal-content is-unbounded-model">
|
||||
|
|
@ -495,6 +535,92 @@ const dialog_confirm = ref<{
|
|||
options: [],
|
||||
})
|
||||
|
||||
const paginationInfo = computed(() => stateStore.getPagination())
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const currentPageFromUrl = computed(() => {
|
||||
const pageParam = route.query.page
|
||||
if (!pageParam) {
|
||||
return 1
|
||||
}
|
||||
const pageStr = Array.isArray(pageParam) ? pageParam[0] : pageParam
|
||||
if (!pageStr) {
|
||||
return 1
|
||||
}
|
||||
const page = parseInt(pageStr, 10)
|
||||
return isNaN(page) || page < 1 ? 1 : page
|
||||
})
|
||||
|
||||
const navigateToPage = async (page: number) => {
|
||||
if (page < 1 || page > paginationInfo.value.total_pages || paginationInfo.value.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await router.push({ query: { ...route.query, page: page.toString() } })
|
||||
await stateStore.loadPaginated('history', page, config.app.default_pagination, 'DESC')
|
||||
} catch (error) {
|
||||
console.error('Failed to navigate to page:', error)
|
||||
toast.error('Failed to load page')
|
||||
}
|
||||
}
|
||||
|
||||
watch(showCompleted, async isShown => {
|
||||
if (isShown && !paginationInfo.value.isLoaded && socket.isConnected) {
|
||||
try {
|
||||
const pageToLoad = currentPageFromUrl.value
|
||||
await stateStore.loadPaginated('history', pageToLoad, config.app.default_pagination, 'DESC')
|
||||
} catch (error) {
|
||||
console.error('Failed to load history:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query.page, async newPage => {
|
||||
if (!showCompleted.value || !paginationInfo.value.isLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
const pageStr = newPage ? (Array.isArray(newPage) ? newPage[0] : newPage) : undefined
|
||||
const page = pageStr ? parseInt(pageStr, 10) : 1
|
||||
if (isNaN(page) || page < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
if (page !== paginationInfo.value.page) {
|
||||
try {
|
||||
await stateStore.loadPaginated('history', page, config.app.default_pagination, 'DESC')
|
||||
} catch (error) {
|
||||
console.error('Failed to load page from URL:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (showCompleted.value && !paginationInfo.value.isLoaded && socket.isConnected) {
|
||||
try {
|
||||
const pageToLoad = currentPageFromUrl.value
|
||||
await stateStore.loadPaginated('history', pageToLoad, config.app.default_pagination, 'DESC')
|
||||
} catch (error) {
|
||||
console.error('Failed to load history on mount:', error)
|
||||
toast.error('Failed to load history')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => socket.isConnected, async connected => {
|
||||
if (connected && showCompleted.value && !paginationInfo.value.isLoaded) {
|
||||
try {
|
||||
const pageToLoad = currentPageFromUrl.value
|
||||
await stateStore.loadPaginated('history', pageToLoad, config.app.default_pagination, 'DESC')
|
||||
} catch (error) {
|
||||
console.error('Failed to load history after socket connection:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const showThumbnails = computed(() => (props.thumbnails ?? true) && !hideThumbnail.value)
|
||||
|
||||
const playVideo = (item: StoreItem) => { video_item.value = item }
|
||||
|
|
|
|||
|
|
@ -1,45 +1,67 @@
|
|||
<template>
|
||||
<div class="notification" :class="props.message_class">
|
||||
<button class="delete" @click="emit('close')" v-if="!props.useToggle && props.useClose"></button>
|
||||
|
||||
<div @click="emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="props.useToggle">
|
||||
<div :class="[newStyle ? 'message' : 'notification', message_class]">
|
||||
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose && !newStyle"></button>
|
||||
<div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="{ 'fa-arrow-up': props.toggle, 'fa-arrow-down': !props.toggle }"></i>
|
||||
<i class="fas" :class="{'fa-arrow-up':toggle,'fa-arrow-down':!toggle}"></i>
|
||||
</span>
|
||||
<span>{{ props.toggle ? 'Close' : 'Open' }}</span>
|
||||
<span>{{ toggle ? 'Close' : 'Open' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="notification-title is-unselectable" :class="{ 'is-clickable': props.useToggle }"
|
||||
v-if="props.title || props.icon" @click="props.useToggle ? emit('toggle', props.toggle) : null">
|
||||
<template v-if="props.icon">
|
||||
<div class="is-unselectable"
|
||||
:class="{'is-clickable':useToggle, 'notification-title': !newStyle, 'message-header': newStyle}"
|
||||
v-if="title || icon"
|
||||
@click="true === useToggle ? $emit('toggle', toggle): null">
|
||||
<template v-if="icon">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i :class="props.icon"></i></span>
|
||||
<span>{{ props.title }}</span>
|
||||
<span class="icon"><i :class="icon"></i></span>
|
||||
<span>{{ title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ props.title }}</template>
|
||||
<template v-else>{{ title }}</template>
|
||||
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose && newStyle"/>
|
||||
</div>
|
||||
|
||||
<div class="notification-content content" v-if="!props.useToggle || props.toggle">
|
||||
<template v-if="props.message">{{ props.message }}</template>
|
||||
<slot />
|
||||
<div class="content is-text-break" v-if="false === useToggle || toggle"
|
||||
:class="{'notification-body': !newStyle, 'message-body': newStyle}">
|
||||
<template v-if="message">{{ message }}</template>
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps < {
|
||||
title?: string
|
||||
icon?: string
|
||||
message?: string
|
||||
message_class?: string
|
||||
useToggle?: boolean
|
||||
toggle?: boolean
|
||||
useClose?: boolean
|
||||
} > ()
|
||||
import {defineEmits, defineProps} from 'vue'
|
||||
|
||||
const emit = defineEmits < {
|
||||
(e: 'toggle', value ?: boolean): void
|
||||
withDefaults(defineProps<{
|
||||
/** Title text for the notification */
|
||||
title?: string | null
|
||||
/** Icon class for the notification */
|
||||
icon?: string | null
|
||||
/** Main message content */
|
||||
message?: string | null
|
||||
/** CSS class for the notification */
|
||||
message_class?: string
|
||||
/** If true, show toggle button */
|
||||
useToggle?: boolean
|
||||
/** Current toggle state */
|
||||
toggle?: boolean
|
||||
/** If true, show close button */
|
||||
useClose?: boolean,
|
||||
newStyle?: boolean
|
||||
}>(), {
|
||||
title: null,
|
||||
icon: null,
|
||||
message: null,
|
||||
message_class: 'is-info',
|
||||
useToggle: false,
|
||||
toggle: false,
|
||||
useClose: false,
|
||||
newStyle: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
/** Emitted when the toggle button is clicked */
|
||||
(e: 'toggle', value?: boolean): void
|
||||
/** Emitted when the close button is clicked */
|
||||
(e: 'close'): void
|
||||
}> ()
|
||||
</script>
|
||||
}>()
|
||||
</script>
|
||||
70
ui/app/components/Pager.vue
Normal file
70
ui/app/components/Pager.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button rel="first" class="button" v-if="page !== 1" @click="changePage(1)" :disabled="isLoading"
|
||||
:class="{'is-loading':isLoading}">
|
||||
<span class="icon"><i class="fas fa-angle-double-left"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button rel="prev" class="button" v-if="page > 1 && (page-1) !== 1" @click="changePage(page-1)"
|
||||
:disabled="isLoading" :class="{'is-loading':isLoading}">
|
||||
<span class="icon"><i class="fas fa-angle-left"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select id="pager_list" v-model="currentPage" @change="changePage(currentPage)" :disabled="isLoading">
|
||||
<option v-for="(item, index) in makePagination(page, last_page)" :key="`pager-${index}`"
|
||||
:value="item.page" :disabled="0 === item.page">
|
||||
{{ item.text }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button rel="next" class="button" v-if="page !== last_page && ( page + 1 ) !== last_page"
|
||||
@click="changePage( page + 1 )" :disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
||||
<span class="icon"><i class="fas fa-angle-right"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button rel="last" class="button" v-if="page !== last_page" @click="changePage(last_page)"
|
||||
:disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
||||
<span class="icon"><i class="fas fa-angle-double-right"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {makePagination} from '~/utils/index'
|
||||
|
||||
const emitter = defineEmits(['navigate'])
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
last_page: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
})
|
||||
|
||||
const changePage = p => {
|
||||
if (p < 1 || p > props.last_page) {
|
||||
return
|
||||
}
|
||||
emitter('navigate', p)
|
||||
currentPage.value = p
|
||||
}
|
||||
|
||||
const currentPage = ref(props.page)
|
||||
</script>
|
||||
|
|
@ -25,6 +25,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||
started: 0,
|
||||
app_env: 'production',
|
||||
simple_mode: false,
|
||||
default_pagination: 50,
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
const json = JSON.parse(stream)
|
||||
config.setAll({
|
||||
app: json.data.config,
|
||||
tasks: json.data.tasks,
|
||||
presets: json.data.presets,
|
||||
dl_fields: json.data.dl_fields,
|
||||
paused: Boolean(json.data.paused)
|
||||
|
|
@ -108,9 +107,22 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
|
||||
on('connected', stream => {
|
||||
const json = JSON.parse(stream);
|
||||
config.add('folders', json.data.folders)
|
||||
stateStore.addAll('queue', json.data.queue || {})
|
||||
stateStore.addAll('history', json.data.done || {})
|
||||
if (!json?.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.data?.folder) {
|
||||
config.add('folders', json.data.folders)
|
||||
}
|
||||
|
||||
if (json.data?.queue) {
|
||||
stateStore.addAll('queue', json.data.queue || {})
|
||||
}
|
||||
|
||||
if (typeof json.data?.history_count === 'number') {
|
||||
stateStore.setHistoryCount(json.data.history_count)
|
||||
}
|
||||
|
||||
error.value = null;
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import type { StoreItem } from '~/types/store'
|
||||
import { request } from '~/utils'
|
||||
|
||||
type StateType = 'queue' | 'history'
|
||||
type KeyType = string
|
||||
|
|
@ -7,10 +8,33 @@ type KeyType = string
|
|||
interface State {
|
||||
queue: Record<KeyType, StoreItem>
|
||||
history: Record<KeyType, StoreItem>
|
||||
pagination: {
|
||||
page: number
|
||||
per_page: number
|
||||
total: number
|
||||
total_pages: number
|
||||
has_next: boolean
|
||||
has_prev: boolean
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const useStateStore = defineStore('state', () => {
|
||||
const state = reactive<State>({ queue: {}, history: {} })
|
||||
const state = reactive<State>({
|
||||
queue: {},
|
||||
history: {},
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
total: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
isLoaded: false,
|
||||
isLoading: false,
|
||||
},
|
||||
})
|
||||
|
||||
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||
state[type][key] = value
|
||||
|
|
@ -37,6 +61,15 @@ export const useStateStore = defineStore('state', () => {
|
|||
|
||||
const clearAll = (type: StateType): void => {
|
||||
state[type] = {}
|
||||
if ('queue' === type) {
|
||||
return
|
||||
}
|
||||
|
||||
state.pagination.total = 0
|
||||
state.pagination.page = 1
|
||||
state.pagination.total_pages = 0
|
||||
state.pagination.has_next = false
|
||||
state.pagination.has_prev = false
|
||||
}
|
||||
|
||||
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
|
||||
|
|
@ -52,8 +85,96 @@ export const useStateStore = defineStore('state', () => {
|
|||
}
|
||||
|
||||
const count = (type: StateType): number => {
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
return state.pagination.total
|
||||
}
|
||||
return Object.keys(state[type]).length
|
||||
}
|
||||
|
||||
return { ...toRefs(state), add, update, remove, get, has, clearAll, addAll, move, count }
|
||||
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC'): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
state.pagination.isLoading = true
|
||||
|
||||
per_page = 2 // For testing purposes, set per_page to 2
|
||||
|
||||
try {
|
||||
const search = new URLSearchParams({ type: 'done', page: page.toString(), per_page: per_page.toString(), order });
|
||||
|
||||
const response = await request(`/api/history?${search}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.pagination) {
|
||||
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false, }
|
||||
const items: Record<KeyType, StoreItem> = {}
|
||||
for (const item of data.items || []) {
|
||||
items[item._id] = item
|
||||
}
|
||||
state[type] = items
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load ${type} page ${page}:`, error)
|
||||
state.pagination.isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadNextPage = async (type: StateType): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
if (!state.pagination.has_next || state.pagination.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page)
|
||||
}
|
||||
|
||||
const loadPreviousPage = async (type: StateType): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
if (!state.pagination.has_prev || state.pagination.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page)
|
||||
}
|
||||
|
||||
const reloadCurrentPage = async (type: StateType): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
if (!state.pagination.isLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
await loadPaginated(type, state.pagination.page, state.pagination.per_page)
|
||||
}
|
||||
|
||||
const getPagination = () => state.pagination
|
||||
|
||||
const setHistoryCount = (count: number) => state.pagination.total = count
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
add,
|
||||
update,
|
||||
remove,
|
||||
get,
|
||||
has,
|
||||
clearAll,
|
||||
addAll,
|
||||
move,
|
||||
count,
|
||||
loadPaginated,
|
||||
loadNextPage,
|
||||
loadPreviousPage,
|
||||
reloadCurrentPage,
|
||||
getPagination,
|
||||
setHistoryCount,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
2
ui/app/types/config.d.ts
vendored
2
ui/app/types/config.d.ts
vendored
|
|
@ -42,6 +42,8 @@ type AppConfig = {
|
|||
started: number,
|
||||
/** Application environment, e.g. "production", "development" */
|
||||
app_env: "production" | "development"
|
||||
/** Default number of items per page for pagination */
|
||||
default_pagination: number
|
||||
}
|
||||
|
||||
type Preset = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue