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:
neon
2026-03-16 08:33:03 -06:00
parent d0329aa391
commit 854ab783c8
7 changed files with 319 additions and 61 deletions
@@ -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 }
}