mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: multi-format read and download support
- add split Read button on book detail page (primary format direct, dropdown for others) - add useBookDownload composable with auth-aware blob download and ZIP export - add BookDownloadButton component for detail page and files tab - refactor useBookBulkActions to delegate export to useBookDownload - add Open/Download submenus to book card dropdown - group Edit/Refresh/Regenerate under Metadata submenu in book card
This commit is contained in:
@@ -8,6 +8,7 @@ import { useRouter } from 'vue-router'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
Image,
|
||||
@@ -21,12 +22,22 @@ import {
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
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 { useBookDownload } from '@/features/book/composables/useBookDownload'
|
||||
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -54,7 +65,6 @@ const seriesLine = computed(() => {
|
||||
|
||||
const readableFiles = computed(() => props.book.files.filter((f) => f.format && f.format in FORMAT_TO_GROUP))
|
||||
const primaryFile = computed(() => readableFiles.value.find((f) => f.role === 'primary') ?? readableFiles.value[0] ?? null)
|
||||
const extraFiles = computed(() => readableFiles.value.filter((f) => f !== primaryFile.value))
|
||||
|
||||
const { coverUrl, bumpVersion } = useCoverVersions()
|
||||
const coverSrc = computed(() => coverUrl(props.book.id))
|
||||
@@ -129,6 +139,16 @@ function openAuthorBrowse() {
|
||||
if (!authorQuery.value) return
|
||||
void router.push({ name: 'authors', query: { q: authorQuery.value } })
|
||||
}
|
||||
|
||||
const { downloadFile, exportBooks } = useBookDownload()
|
||||
|
||||
function handleDownloadFile(file: BookFileRef) {
|
||||
void downloadFile(file.id)
|
||||
}
|
||||
|
||||
function handleExportAll() {
|
||||
void exportBooks([props.book.id], true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -294,33 +314,71 @@ function openAuthorBrowse() {
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem v-if="primaryFile" @click="openFile(primaryFile)">
|
||||
<!-- Open submenu (only when multiple readable files; single file = direct item) -->
|
||||
<DropdownMenuItem v-if="readableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
{{ extraFiles.length > 0 ? `Open as ${primaryFile.format?.toUpperCase() ?? 'primary'}` : 'Open' }}
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-for="file in extraFiles" :key="file.id" @click="openFile(file)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open as {{ file.format?.toUpperCase() ?? '?' }}
|
||||
<DropdownMenuSub v-else-if="readableFiles.length > 1 && !isMissing">
|
||||
<DropdownMenuSubTrigger>
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in readableFiles" :key="file.id" @click="openFile(file)">
|
||||
{{ file.format?.toUpperCase() ?? '?' }}
|
||||
<span v-if="file.role === 'primary'" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<!-- Download submenu -->
|
||||
<DropdownMenuItem v-if="readableFiles.length === 1 && primaryFile" @click="handleDownloadFile(primaryFile)">
|
||||
<Download class="size-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub v-else-if="readableFiles.length > 1">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download class="size-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in readableFiles" :key="file.id" @click="handleDownloadFile(file)">
|
||||
{{ file.format?.toUpperCase() ?? '?' }}
|
||||
<span v-if="file.role === 'primary'" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleExportAll"> All formats (ZIP) </DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id } })">
|
||||
<ExternalLink class="size-4 mr-2" />
|
||||
Book Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="router.push({ name: 'book-edit', params: { bookId: book.id } })">
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Edit Metadata
|
||||
</DropdownMenuItem>
|
||||
<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>
|
||||
<DropdownMenuItem v-if="hasPermission('library_edit_metadata')" :disabled="reExtractingCover" @click="reExtractCover()">
|
||||
<Loader2 v-if="reExtractingCover" class="size-4 mr-2 animate-spin" />
|
||||
<Image v-else class="size-4 mr-2" />
|
||||
Regenerate Cover
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Metadata
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem @click="router.push({ name: 'book-edit', params: { bookId: book.id } })">
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Edit Metadata
|
||||
</DropdownMenuItem>
|
||||
<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>
|
||||
<DropdownMenuItem v-if="hasPermission('library_edit_metadata')" :disabled="reExtractingCover" @click="reExtractCover()">
|
||||
<Loader2 v-if="reExtractingCover" class="size-4 mr-2 animate-spin" />
|
||||
<Image v-else class="size-4 mr-2" />
|
||||
Regenerate Cover
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
|
||||
<FolderPlus class="size-4 mr-2" />
|
||||
Add to Collection
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronDown, Download } from 'lucide-vue-next'
|
||||
import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
import type { BookDetailFile } from '@projectx/types'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getFormatColor } from '@/features/book/lib/format-colors'
|
||||
import { useBookDownload } from '@/features/book/composables/useBookDownload'
|
||||
|
||||
const props = defineProps<{
|
||||
files: BookDetailFile[]
|
||||
bookId: number
|
||||
}>()
|
||||
|
||||
const { isDownloading, downloadFile, exportBooks } = useBookDownload()
|
||||
|
||||
const readableFiles = computed(() => props.files.filter((f) => f.format && f.format in FORMAT_TO_GROUP))
|
||||
|
||||
const primaryFile = computed(() => readableFiles.value.find((f) => f.role === 'primary') ?? readableFiles.value[0] ?? null)
|
||||
|
||||
const hasMultiple = computed(() => readableFiles.value.length > 1)
|
||||
|
||||
function formatBadgeStyle(fmt: string) {
|
||||
const color = getFormatColor(fmt)
|
||||
return { color, borderColor: `${color}66`, backgroundColor: `${color}1a` }
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function handleSingleDownload() {
|
||||
if (primaryFile.value) downloadFile(primaryFile.value.id)
|
||||
}
|
||||
|
||||
function handleFileDownload(file: BookDetailFile) {
|
||||
downloadFile(file.id)
|
||||
}
|
||||
|
||||
function handleExportAll() {
|
||||
exportBooks([props.bookId], true)
|
||||
}
|
||||
|
||||
function handleExportPrimary() {
|
||||
exportBooks([props.bookId], false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip v-if="!hasMultiple">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex w-full items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile || isDownloading"
|
||||
@click="handleSingleDownload"
|
||||
>
|
||||
<Download class="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Popover v-else>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-1.5 h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile || isDownloading"
|
||||
title="Download"
|
||||
>
|
||||
<Download class="size-3.5" />
|
||||
<ChevronDown class="size-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-52 p-1" align="start">
|
||||
<button
|
||||
v-for="file in readableFiles"
|
||||
:key="file.id"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
@click="handleFileDownload(file)"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
|
||||
:style="formatBadgeStyle(file.format ?? '?')"
|
||||
>{{ file.format ?? '?' }}</span
|
||||
>
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ formatFileSize(file.sizeBytes) }}</span>
|
||||
<span v-if="file.role === 'primary'" class="text-[10px] text-primary font-medium shrink-0">Primary</span>
|
||||
</button>
|
||||
<div class="my-1 border-t border-border" />
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-muted-foreground"
|
||||
@click="handleExportAll"
|
||||
>
|
||||
<Download class="size-3.5 shrink-0" />
|
||||
<span>All formats (ZIP)</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-muted-foreground"
|
||||
@click="handleExportPrimary"
|
||||
>
|
||||
<Download class="size-3.5 shrink-0" />
|
||||
<span>Primary only (ZIP)</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -22,6 +22,9 @@ const globalStubs = {
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSeparator: { template: '<div />' },
|
||||
DropdownMenuSub: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSubTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuSubContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Download, FolderPlus, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { BookOpen, ChevronDown, FolderPlus, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
|
||||
import { bookCoverStyle } from '@/features/book/lib/book-cover'
|
||||
import { getFormatColor } from '@/features/book/lib/format-colors'
|
||||
import { getProviderColor } from '@/lib/provider-colors'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
import type { BookDetail, BookKoboState } from '@projectx/types'
|
||||
import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue'
|
||||
import RecommendedBooksRow from '@/features/book/components/detail/RecommendedBooksRow.vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
@@ -48,6 +50,7 @@ const router = useRouter()
|
||||
const addToCollectionOpen = ref(false)
|
||||
const scoreBreakdownOpen = ref(false)
|
||||
const moreMenuOpen = ref(false)
|
||||
const readMenuOpen = ref(false)
|
||||
|
||||
const { weights: scoreWeights, fetchWeights } = useMetadataScoreWeights()
|
||||
|
||||
@@ -77,6 +80,8 @@ const { coverUrl } = useCoverVersions()
|
||||
const coverSrc = computed(() => coverUrl(props.book.id, 'cover'))
|
||||
|
||||
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const readableFiles = computed(() => props.book.files.filter((f) => f.format && f.format in FORMAT_TO_GROUP))
|
||||
const hasMultipleFiles = computed(() => readableFiles.value.length > 1)
|
||||
const authorLine = computed(() => props.book.authors.map((a) => a.name).join(', ') || null)
|
||||
const formats = computed(() => [...new Set(props.book.files.map((f) => f.format ?? '?'))])
|
||||
|
||||
@@ -302,11 +307,13 @@ function openBook() {
|
||||
})
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
if (!primaryFile.value) return
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/v1/books/files/${primaryFile.value.id}/serve?download=1`
|
||||
a.click()
|
||||
function openBookFile(file: BookDetail['files'][number]) {
|
||||
readMenuOpen.value = false
|
||||
router.push({
|
||||
name: 'reader',
|
||||
params: { bookId: props.book.id, fileId: file.id },
|
||||
query: { format: file.format ?? 'epub' },
|
||||
})
|
||||
}
|
||||
|
||||
let supplementalRequestId = 0
|
||||
@@ -444,7 +451,46 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<!-- Read button: split when multiple files, plain when single -->
|
||||
<div v-if="hasMultipleFiles" class="flex w-full h-9 rounded-md overflow-hidden">
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
@click="openBook"
|
||||
>
|
||||
<BookOpen class="size-4" />
|
||||
Read
|
||||
</button>
|
||||
<div class="w-px bg-primary-foreground/20 shrink-0" />
|
||||
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="Choose format"
|
||||
>
|
||||
<ChevronDown class="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-52 p-1" align="end">
|
||||
<button
|
||||
v-for="file in readableFiles"
|
||||
:key="file.id"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
@click="openBookFile(file)"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
|
||||
:style="formatBadgeStyle(file.format ?? '?')"
|
||||
>{{ file.format ?? '?' }}</span
|
||||
>
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ formatFileSize(file.sizeBytes) }}</span>
|
||||
<span v-if="file.role === 'primary'" class="text-[10px] text-primary font-medium shrink-0">Primary</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="flex w-full items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
@click="openBook"
|
||||
@@ -452,19 +498,11 @@ watch(
|
||||
<BookOpen class="size-4" />
|
||||
Read
|
||||
</button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="!primaryFile"
|
||||
@click="downloadFile"
|
||||
>
|
||||
<Download class="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download</TooltipContent>
|
||||
</Tooltip>
|
||||
<div class="flex-1">
|
||||
<BookDownloadButton :files="book.files" :book-id="book.id" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
|
||||
@@ -5,10 +5,13 @@ import { BookOpen, Download, Files, History } from 'lucide-vue-next'
|
||||
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@projectx/types'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { api } from '@/lib/api'
|
||||
import { useBookDownload } from '@/features/book/composables/useBookDownload'
|
||||
|
||||
const props = defineProps<{ book: BookDetail }>()
|
||||
const router = useRouter()
|
||||
|
||||
const { downloadFile: downloadBookFile } = useBookDownload()
|
||||
|
||||
const READABLE_FORMATS = new Set(['epub', 'pdf', 'cbz'])
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
@@ -44,9 +47,7 @@ function openFile(file: BookDetailFile) {
|
||||
}
|
||||
|
||||
function downloadFile(file: BookDetailFile) {
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/v1/books/files/${file.id}/serve?download=1`
|
||||
a.click()
|
||||
void downloadBookFile(file.id)
|
||||
}
|
||||
|
||||
function fileIconBg(format: string | null): string {
|
||||
|
||||
@@ -3,10 +3,12 @@ import { api } from '@/lib/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useCoverVersions } from './useCoverVersions'
|
||||
import { useRefreshingBooks } from './useRefreshingBooks'
|
||||
import { useBookDownload } from './useBookDownload'
|
||||
|
||||
export function useBookBulkActions(selectedIds: Ref<Set<number>>, onDeleted: (ids: number[]) => void) {
|
||||
const { bumpVersion } = useCoverVersions()
|
||||
const { markRefreshing, clearRefreshing } = useRefreshingBooks()
|
||||
const { exportBooks } = useBookDownload()
|
||||
|
||||
async function handleBulkRefreshMetadata() {
|
||||
const ids = [...selectedIds.value]
|
||||
@@ -104,25 +106,7 @@ export function useBookBulkActions(selectedIds: Ref<Set<number>>, onDeleted: (id
|
||||
|
||||
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)
|
||||
await exportBooks(ids, allFormats)
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export function useBookDownload() {
|
||||
const isDownloading = ref(false)
|
||||
|
||||
async function downloadFile(fileId: number): Promise<void> {
|
||||
isDownloading.value = true
|
||||
try {
|
||||
const res = await api(`/api/v1/books/files/${fileId}/serve?download=1`)
|
||||
if (!res.ok) {
|
||||
toast.error('Download failed')
|
||||
return
|
||||
}
|
||||
const disposition = res.headers.get('Content-Disposition') ?? ''
|
||||
const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)["']?/i)
|
||||
const filename = match?.[1] ? decodeURIComponent(match[1].trim()) : 'download'
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast.error('Download failed')
|
||||
} finally {
|
||||
isDownloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportBooks(bookIds: number[], allFormats: boolean): Promise<void> {
|
||||
if (bookIds.length === 0) return
|
||||
const label = `${bookIds.length} book${bookIds.length === 1 ? '' : 's'}`
|
||||
const toastId = toast.loading(`Preparing ${label} for download...`)
|
||||
isDownloading.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/books/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookIds, 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)
|
||||
} catch {
|
||||
toast.dismiss(toastId)
|
||||
toast.error('Export failed')
|
||||
} finally {
|
||||
isDownloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { isDownloading, downloadFile, exportBooks }
|
||||
}
|
||||
Reference in New Issue
Block a user