mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: stream bulk metadata refresh and cover re-extraction via SSE
- add useRefreshingBooks composable for shared per-book spinner state - show refresh spinner on BookCoverCard during any bulk operation - bulk-refresh-metadata and bulk-re-extract-cover endpoints now stream SSE - client reads SSE stream and updates each card cover+spinner one-by-one - wire all missing SelectionActionBar events in LensView and CollectionView
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
import { useRefreshMetadata } from '../composables/useRefreshMetadata'
|
||||
import { useRefreshingBooks } from '../composables/useRefreshingBooks'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
|
||||
@@ -58,6 +59,8 @@ const { coverUrl, bumpVersion } = useCoverVersions()
|
||||
const coverSrc = computed(() => coverUrl(props.book.id))
|
||||
|
||||
const { refreshing, refreshWithFeedback } = useRefreshMetadata()
|
||||
const { isRefreshing } = useRefreshingBooks()
|
||||
const anyRefreshing = computed(() => refreshing.value || isRefreshing(props.book.id))
|
||||
const reExtractingCover = ref(false)
|
||||
|
||||
async function reExtractCover() {
|
||||
@@ -227,7 +230,7 @@ function handleCardClick(event: MouseEvent) {
|
||||
|
||||
<!-- Refresh spinner overlay -->
|
||||
<Transition name="fade">
|
||||
<div v-if="refreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/30 backdrop-blur-[1.5px]">
|
||||
<div v-if="anyRefreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/30 backdrop-blur-[1.5px]">
|
||||
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -294,8 +297,8 @@ function handleCardClick(event: MouseEvent) {
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Edit Metadata
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem :disabled="refreshing" @click="refreshWithFeedback(book.id)">
|
||||
<Loader2 v-if="refreshing" class="size-4 mr-2 animate-spin" />
|
||||
<DropdownMenuItem :disabled="anyRefreshing" @click="refreshWithFeedback(book.id)">
|
||||
<Loader2 v-if="anyRefreshing" class="size-4 mr-2 animate-spin" />
|
||||
<RefreshCw v-else class="size-4 mr-2" />
|
||||
Refresh Metadata
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const refreshingIds = ref(new Set<number>())
|
||||
|
||||
export function useRefreshingBooks() {
|
||||
function markRefreshing(ids: number[]) {
|
||||
const next = new Set(refreshingIds.value)
|
||||
ids.forEach((id) => next.add(id))
|
||||
refreshingIds.value = next
|
||||
}
|
||||
|
||||
function clearRefreshing(ids: number[]) {
|
||||
const next = new Set(refreshingIds.value)
|
||||
ids.forEach((id) => next.delete(id))
|
||||
refreshingIds.value = next
|
||||
}
|
||||
|
||||
function isRefreshing(id: number): boolean {
|
||||
return refreshingIds.value.has(id)
|
||||
}
|
||||
|
||||
return { markRefreshing, clearRefreshing, isRefreshing }
|
||||
}
|
||||
@@ -11,12 +11,15 @@ import ViewHeader from '@/components/ViewHeader.vue'
|
||||
import SelectionActionBar from '@/components/SelectionActionBar.vue'
|
||||
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
|
||||
import EditCollectionDialog from '@/features/collection/components/EditCollectionDialog.vue'
|
||||
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
|
||||
import { SidebarInset } from '@/components/ui/sidebar'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import { useCollections } from '@/features/collection/composables/useCollections'
|
||||
import { useCollectionBooks } from '@/features/collection/composables/useCollectionBooks'
|
||||
import { useBookSelection } from '@/features/book/composables/useBookSelection'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
@@ -50,9 +53,113 @@ function toggleSelectionMode() {
|
||||
}
|
||||
|
||||
const addToCollectionOpen = ref(false)
|
||||
const sendBookOpen = ref(false)
|
||||
const editCollectionOpen = ref(false)
|
||||
let removingInProgress = false
|
||||
let deletingInProgress = false
|
||||
const { bumpVersion } = useCoverVersions()
|
||||
const { markRefreshing, clearRefreshing } = useRefreshingBooks()
|
||||
|
||||
async function handleBulkRefreshMetadata() {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
markRefreshing(ids)
|
||||
const res = await api('/api/v1/books/bulk-refresh-metadata', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
clearRefreshing(ids)
|
||||
toast.error('Failed to refresh metadata')
|
||||
return
|
||||
}
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
|
||||
if (data.done) { processed = data.processed; failed = data.failed }
|
||||
} catch { /* ignore malformed line */ }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearRefreshing(ids)
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.warning(`Refreshed ${processed} book${processed === 1 ? '' : 's'}, ${failed} failed`)
|
||||
} else {
|
||||
toast.success(`Refreshed metadata for ${processed} book${processed === 1 ? '' : 's'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkReExtractCover() {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
markRefreshing(ids)
|
||||
const res = await api('/api/v1/books/bulk-re-extract-cover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
clearRefreshing(ids)
|
||||
toast.error('Failed to re-extract covers')
|
||||
return
|
||||
}
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let processed = 0
|
||||
let updated = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
|
||||
if (data.done) { processed = data.processed; updated = data.updated }
|
||||
} catch { /* ignore malformed line */ }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearRefreshing(ids)
|
||||
}
|
||||
toast.success(`Re-extracted ${updated} of ${processed} cover${processed === 1 ? '' : 's'}`)
|
||||
}
|
||||
|
||||
async function handleExport(allFormats: boolean) {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
const toastId = toast.loading(`Preparing ${ids.length} book${ids.length === 1 ? '' : 's'} for download...`)
|
||||
const res = await api('/api/v1/books/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids, allFormats }),
|
||||
})
|
||||
toast.dismiss(toastId)
|
||||
if (!res.ok) {
|
||||
toast.error('Export failed')
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'books.zip'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
const quickViewBookId = ref<number | null>(null)
|
||||
const quickViewOpen = ref(false)
|
||||
|
||||
@@ -146,8 +253,12 @@ watch(
|
||||
:visible="selectionMode"
|
||||
:count="selectedCount"
|
||||
:in-collection="true"
|
||||
@send="sendBookOpen = true"
|
||||
@export="handleExport"
|
||||
@add-to-collection="addToCollectionOpen = true"
|
||||
@remove-from-collection="handleRemoveFromCollection"
|
||||
@refresh-metadata="handleBulkRefreshMetadata"
|
||||
@re-extract-cover="handleBulkReExtractCover"
|
||||
@delete="handleDeleteSelected"
|
||||
@exit="exitSelectionMode"
|
||||
/>
|
||||
@@ -160,6 +271,7 @@ watch(
|
||||
/>
|
||||
|
||||
<EditCollectionDialog v-if="collection" :open="editCollectionOpen" :collection="collection" @close="editCollectionOpen = false" />
|
||||
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
|
||||
|
||||
<SidebarInset class="flex flex-col h-screen glow-wrapper">
|
||||
<AppHeader />
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useBookQuery, type BookCard } from '@/features/book/composables/useBook
|
||||
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||
import { useBookSelection } from '@/features/book/composables/useBookSelection'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
@@ -195,6 +196,7 @@ watch(
|
||||
|
||||
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection()
|
||||
const { bumpVersion } = useCoverVersions()
|
||||
const { markRefreshing, clearRefreshing } = useRefreshingBooks()
|
||||
|
||||
function handleSelect(id: number, event: MouseEvent) {
|
||||
if (event.shiftKey)
|
||||
@@ -235,19 +237,37 @@ async function handleDeleteSelected() {
|
||||
async function handleBulkRefreshMetadata() {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
const toastId = toast.loading(`Refreshing metadata for ${ids.length} book${ids.length === 1 ? '' : 's'}...`)
|
||||
markRefreshing(ids)
|
||||
const res = await api('/api/v1/books/bulk-refresh-metadata', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
toast.dismiss(toastId)
|
||||
if (!res.ok) {
|
||||
clearRefreshing(ids)
|
||||
toast.error('Failed to refresh metadata')
|
||||
return
|
||||
}
|
||||
const { processed, failed } = await res.json()
|
||||
ids.forEach((id) => bumpVersion(id))
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
|
||||
if (data.done) { processed = data.processed; failed = data.failed }
|
||||
} catch { /* ignore malformed line */ }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearRefreshing(ids)
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.warning(`Refreshed ${processed} book${processed === 1 ? '' : 's'}, ${failed} failed`)
|
||||
} else {
|
||||
@@ -258,19 +278,37 @@ async function handleBulkRefreshMetadata() {
|
||||
async function handleBulkReExtractCover() {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
const toastId = toast.loading(`Re-extracting covers for ${ids.length} book${ids.length === 1 ? '' : 's'}...`)
|
||||
markRefreshing(ids)
|
||||
const res = await api('/api/v1/books/bulk-re-extract-cover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
toast.dismiss(toastId)
|
||||
if (!res.ok) {
|
||||
clearRefreshing(ids)
|
||||
toast.error('Failed to re-extract covers')
|
||||
return
|
||||
}
|
||||
const { processed, updated } = await res.json()
|
||||
ids.forEach((id) => bumpVersion(id))
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let processed = 0
|
||||
let updated = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
|
||||
if (data.done) { processed = data.processed; updated = data.updated }
|
||||
} catch { /* ignore malformed line */ }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearRefreshing(ids)
|
||||
}
|
||||
toast.success(`Re-extracted ${updated} of ${processed} cover${processed === 1 ? '' : 's'}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import ViewHeader from '@/components/ViewHeader.vue'
|
||||
import LensEditorPanel from '@/features/lens/components/LensEditorPanel.vue'
|
||||
import SelectionActionBar from '@/components/SelectionActionBar.vue'
|
||||
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
|
||||
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
|
||||
import { SidebarInset } from '@/components/ui/sidebar'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
@@ -17,6 +18,8 @@ import { useLens } from '@/features/lens/composables/useLens'
|
||||
import { useLenses } from '@/features/lens/composables/useLenses'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useBookSelection } from '@/features/book/composables/useBookSelection'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import FilterSummary from '@/features/book/components/FilterSummary.vue'
|
||||
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
|
||||
@@ -60,6 +63,110 @@ function toggleSelectionMode() {
|
||||
}
|
||||
|
||||
const addToCollectionOpen = ref(false)
|
||||
const sendBookOpen = ref(false)
|
||||
const { bumpVersion } = useCoverVersions()
|
||||
const { markRefreshing, clearRefreshing } = useRefreshingBooks()
|
||||
|
||||
async function handleBulkRefreshMetadata() {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
markRefreshing(ids)
|
||||
const res = await api('/api/v1/books/bulk-refresh-metadata', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
clearRefreshing(ids)
|
||||
toast.error('Failed to refresh metadata')
|
||||
return
|
||||
}
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
|
||||
if (data.done) { processed = data.processed; failed = data.failed }
|
||||
} catch { /* ignore malformed line */ }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearRefreshing(ids)
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.warning(`Refreshed ${processed} book${processed === 1 ? '' : 's'}, ${failed} failed`)
|
||||
} else {
|
||||
toast.success(`Refreshed metadata for ${processed} book${processed === 1 ? '' : 's'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkReExtractCover() {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
markRefreshing(ids)
|
||||
const res = await api('/api/v1/books/bulk-re-extract-cover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
clearRefreshing(ids)
|
||||
toast.error('Failed to re-extract covers')
|
||||
return
|
||||
}
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let processed = 0
|
||||
let updated = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.bookId !== undefined) { bumpVersion(data.bookId); clearRefreshing([data.bookId]) }
|
||||
if (data.done) { processed = data.processed; updated = data.updated }
|
||||
} catch { /* ignore malformed line */ }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearRefreshing(ids)
|
||||
}
|
||||
toast.success(`Re-extracted ${updated} of ${processed} cover${processed === 1 ? '' : 's'}`)
|
||||
}
|
||||
|
||||
async function handleExport(allFormats: boolean) {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
const toastId = toast.loading(`Preparing ${ids.length} book${ids.length === 1 ? '' : 's'} for download...`)
|
||||
const res = await api('/api/v1/books/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids, allFormats }),
|
||||
})
|
||||
toast.dismiss(toastId)
|
||||
if (!res.ok) {
|
||||
toast.error('Export failed')
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'books.zip'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
const ids = [...selectedIds.value]
|
||||
@@ -178,7 +285,11 @@ watch(
|
||||
<SelectionActionBar
|
||||
:visible="selectionMode"
|
||||
:count="selectedCount"
|
||||
@send="sendBookOpen = true"
|
||||
@export="handleExport"
|
||||
@add-to-collection="addToCollectionOpen = true"
|
||||
@refresh-metadata="handleBulkRefreshMetadata"
|
||||
@re-extract-cover="handleBulkReExtractCover"
|
||||
@delete="handleDeleteSelected"
|
||||
@exit="exitSelectionMode"
|
||||
/>
|
||||
@@ -189,6 +300,7 @@ watch(
|
||||
@update:open="addToCollectionOpen = $event"
|
||||
@added="exitSelectionMode"
|
||||
/>
|
||||
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
|
||||
|
||||
<SidebarInset class="flex flex-col h-screen glow-wrapper">
|
||||
<AppHeader />
|
||||
|
||||
Reference in New Issue
Block a user