mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): add single-book delete with confirmation dialog
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{ open: boolean; deleting: boolean }>()
|
||||
const emit = defineEmits<{ confirm: []; cancel: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="emit('cancel')" />
|
||||
<div class="relative z-10 w-full max-w-sm mx-4 bg-card border border-border rounded-xl shadow-2xl p-6">
|
||||
<div class="flex items-start gap-4 mb-5">
|
||||
<div class="shrink-0 h-10 w-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<Trash2 class="text-destructive" :size="18" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-foreground">Delete book?</h2>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
This will permanently delete the book and all its metadata, files, reading progress, and bookmarks. This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="h-9 px-4 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
:disabled="deleting"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="h-9 px-4 rounded-md text-sm font-medium bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
:disabled="deleting"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
<Loader2 v-if="deleting" class="animate-spin" :size="14" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useCoverVersions } from './useCoverVersions'
|
||||
import { useRefreshingBooks } from './useRefreshingBooks'
|
||||
|
||||
export function useBookBulkActions(
|
||||
selectedIds: Ref<Set<number>>,
|
||||
onDeleted: (ids: number[]) => void,
|
||||
) {
|
||||
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 SSE 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 SSE 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]
|
||||
if (ids.length === 0) return
|
||||
const res = await api('/api/v1/books', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to delete books')
|
||||
return
|
||||
}
|
||||
onDeleted(ids)
|
||||
toast.success(`Deleted ${ids.length} book${ids.length === 1 ? '' : 's'}`)
|
||||
}
|
||||
|
||||
return {
|
||||
handleBulkRefreshMetadata,
|
||||
handleBulkReExtractCover,
|
||||
handleExport,
|
||||
handleDeleteSelected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
export function useDeleteBook(onDeleted: (id: number) => void) {
|
||||
const pendingId = ref<number | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
function promptDelete(id: number) {
|
||||
pendingId.value = id
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
pendingId.value = null
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (pendingId.value === null) return
|
||||
const id = pendingId.value
|
||||
deleting.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/books', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: [id] }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to delete book')
|
||||
return
|
||||
}
|
||||
onDeleted(id)
|
||||
toast.success('Book deleted')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
pendingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return { pendingId, deleting, promptDelete, cancelDelete, confirmDelete }
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ 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 DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
|
||||
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 { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import type { BookCard } from '@projectx/types'
|
||||
@@ -54,110 +54,18 @@ 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()
|
||||
const { pendingId: deleteBookId, deleting: deletingBook, promptDelete, cancelDelete, confirmDelete } = useDeleteBook((id) => {
|
||||
books.value = books.value.filter((b) => b.id !== id)
|
||||
})
|
||||
const { handleBulkRefreshMetadata, handleBulkReExtractCover, handleExport, handleDeleteSelected } = useBookBulkActions(
|
||||
selectedIds,
|
||||
(ids) => {
|
||||
const deleted = new Set(ids)
|
||||
books.value = books.value.filter((b) => !deleted.has(b.id))
|
||||
exitSelectionMode()
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -177,31 +85,15 @@ async function handleRemoveFromCollection() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
if (deletingInProgress || selectedIds.value.size === 0) return
|
||||
deletingInProgress = true
|
||||
try {
|
||||
const ids = [...selectedIds.value]
|
||||
const res = await api('/api/v1/books', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to delete books')
|
||||
load(true)
|
||||
exitSelectionMode()
|
||||
toast.success(`Deleted ${ids.length} book${ids.length === 1 ? '' : 's'}`)
|
||||
} catch {
|
||||
toast.error('Failed to delete books')
|
||||
} finally {
|
||||
deletingInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleBookAction(book: BookCard, action: 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete') {
|
||||
if (action === 'quick-view') {
|
||||
quickViewBookId.value = book.id
|
||||
quickViewOpen.value = true
|
||||
return
|
||||
}
|
||||
if (action === 'delete') {
|
||||
promptDelete(book.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +162,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" />
|
||||
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
|
||||
|
||||
<ViewHeader
|
||||
:title="collection?.name ?? 'Collection'"
|
||||
|
||||
+19
-124
@@ -13,13 +13,13 @@ import SelectionActionBar from '@/components/SelectionActionBar.vue'
|
||||
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
|
||||
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
|
||||
import SaveAsLensDialog from '@/features/lens/components/SaveAsLensDialog.vue'
|
||||
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery'
|
||||
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 { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
@@ -193,8 +193,17 @@ watch(
|
||||
)
|
||||
|
||||
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection()
|
||||
const { bumpVersion } = useCoverVersions()
|
||||
const { markRefreshing, clearRefreshing } = useRefreshingBooks()
|
||||
const { pendingId: deleteBookId, deleting: deletingBook, promptDelete, cancelDelete, confirmDelete } = useDeleteBook((id) => {
|
||||
books.value = books.value.filter((b) => b.id !== id)
|
||||
})
|
||||
const { handleBulkRefreshMetadata, handleBulkReExtractCover, handleExport, handleDeleteSelected } = useBookBulkActions(
|
||||
selectedIds,
|
||||
(ids) => {
|
||||
const deleted = new Set(ids)
|
||||
books.value = books.value.filter((b) => !deleted.has(b.id))
|
||||
exitSelectionMode()
|
||||
},
|
||||
)
|
||||
|
||||
function handleSelect(id: number, event: MouseEvent) {
|
||||
if (event.shiftKey)
|
||||
@@ -214,124 +223,6 @@ const addToCollectionOpen = ref(false)
|
||||
const sendBookOpen = ref(false)
|
||||
const saveAsLensOpen = ref(false)
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
const res = await api('/api/v1/books', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to delete books')
|
||||
return
|
||||
}
|
||||
const deleted = new Set(ids)
|
||||
books.value = books.value.filter((b) => !deleted.has(b.id))
|
||||
exitSelectionMode()
|
||||
toast.success(`Deleted ${ids.length} book${ids.length === 1 ? '' : 's'}`)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
|
||||
|
||||
@@ -352,7 +243,9 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
addToCollectionOpen.value = true
|
||||
return
|
||||
}
|
||||
// TODO: implement remaining actions
|
||||
if (action === 'delete') {
|
||||
promptDelete(book.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -555,4 +448,6 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
|
||||
|
||||
<SaveAsLensDialog :open="saveAsLensOpen" :filter="filter" :sort="sort" @close="saveAsLensOpen = false" />
|
||||
|
||||
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
|
||||
</template>
|
||||
|
||||
+19
-123
@@ -10,14 +10,14 @@ 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 DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
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 { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
import { useBookBulkActions } from '@/features/book/composables/useBookBulkActions'
|
||||
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'
|
||||
@@ -62,126 +62,17 @@ 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]
|
||||
if (ids.length === 0) return
|
||||
const res = await api('/api/v1/books', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds: ids }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to delete books')
|
||||
return
|
||||
}
|
||||
load(true)
|
||||
exitSelectionMode()
|
||||
toast.success(`Deleted ${ids.length} book${ids.length === 1 ? '' : 's'}`)
|
||||
}
|
||||
const { pendingId: deleteBookId, deleting: deletingBook, promptDelete, cancelDelete, confirmDelete } = useDeleteBook((id) => {
|
||||
books.value = books.value.filter((b) => b.id !== id)
|
||||
})
|
||||
const { handleBulkRefreshMetadata, handleBulkReExtractCover, handleExport, handleDeleteSelected } = useBookBulkActions(
|
||||
selectedIds,
|
||||
(ids) => {
|
||||
const deleted = new Set(ids)
|
||||
books.value = books.value.filter((b) => !deleted.has(b.id))
|
||||
exitSelectionMode()
|
||||
},
|
||||
)
|
||||
|
||||
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
|
||||
|
||||
@@ -202,6 +93,9 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
addToCollectionOpen.value = true
|
||||
return
|
||||
}
|
||||
if (action === 'delete') {
|
||||
promptDelete(book.id)
|
||||
}
|
||||
}
|
||||
|
||||
const editorOpen = ref(false)
|
||||
@@ -300,6 +194,8 @@ watch(
|
||||
/>
|
||||
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
|
||||
|
||||
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
|
||||
|
||||
<ViewHeader
|
||||
:title="lens?.name ?? 'Lens'"
|
||||
:icon="lens?.icon ?? undefined"
|
||||
|
||||
Reference in New Issue
Block a user