mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
541 lines
22 KiB
Vue
541 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import type { BookCard, BookFileRef } from '@projectx/types'
|
|
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@projectx/types'
|
|
import { bookCoverStyle } from '../lib/book-cover'
|
|
import { getFormatColor } from '../lib/format-colors'
|
|
import { computed, inject, ref, watch, type ComponentPublicInstance } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import {
|
|
BookOpen,
|
|
Check,
|
|
Download,
|
|
ExternalLink,
|
|
FolderPlus,
|
|
Headphones,
|
|
Image,
|
|
Loader2,
|
|
MoreVertical,
|
|
PanelRight,
|
|
Pencil,
|
|
RefreshCw,
|
|
Send,
|
|
Star,
|
|
Trash2,
|
|
TriangleAlert,
|
|
} from 'lucide-vue-next'
|
|
import { useBookStatus, STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS } from '../composables/useBookStatus'
|
|
import type { ReadStatus } from '@projectx/types'
|
|
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 { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
|
|
import { useBookDownload } from '@/features/book/composables/useBookDownload'
|
|
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
|
|
|
|
const router = useRouter()
|
|
|
|
const props = defineProps<{
|
|
book: BookCard
|
|
selectionMode?: boolean
|
|
selected?: boolean
|
|
}>()
|
|
|
|
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
|
|
const emit = defineEmits<{
|
|
action: [type: BookActionType]
|
|
select: [event: MouseEvent]
|
|
}>()
|
|
|
|
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
|
|
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
|
const authorQuery = computed(() => props.book.authors[0] ?? null)
|
|
const seriesLine = computed(() => {
|
|
if (!props.book.seriesName) return null
|
|
const idx = props.book.seriesIndex
|
|
return idx != null ? `${props.book.seriesName} #${idx % 1 === 0 ? Math.floor(idx) : idx}` : props.book.seriesName
|
|
})
|
|
|
|
const readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
|
|
const primaryFile = computed(() => readableFiles.value.find((f) => f.role === 'primary') ?? readableFiles.value[0] ?? null)
|
|
const isAudiobook = computed(() => readableFiles.value.some((f) => FORMAT_TO_GROUP[f.format!] === 'audio'))
|
|
|
|
// For multi-file audiobooks, collapse all tracks into one representative entry.
|
|
// The audio reader loads the full track queue from the book, so opening any track is equivalent.
|
|
const isMultiTrackAudio = computed(() => {
|
|
const audioFiles = readableFiles.value.filter((f) => FORMAT_TO_GROUP[f.format!] === 'audio')
|
|
return audioFiles.length > 1
|
|
})
|
|
const openableFiles = computed(() => {
|
|
if (isMultiTrackAudio.value) {
|
|
const first = readableFiles.value.find((f) => FORMAT_TO_GROUP[f.format!] === 'audio')
|
|
const nonAudio = readableFiles.value.filter((f) => FORMAT_TO_GROUP[f.format!] !== 'audio')
|
|
return first ? [first, ...nonAudio] : nonAudio
|
|
}
|
|
return readableFiles.value
|
|
})
|
|
|
|
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() {
|
|
if (reExtractingCover.value) return
|
|
reExtractingCover.value = true
|
|
try {
|
|
await fetch(`/api/v1/books/${props.book.id}/re-extract-cover`, { method: 'POST' })
|
|
bumpVersion(props.book.id)
|
|
} finally {
|
|
reExtractingCover.value = false
|
|
}
|
|
}
|
|
const { hasPermission } = usePermissions()
|
|
const { cardOverlays } = useDisplaySettings()
|
|
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
|
const showSendDialog = ref(false)
|
|
|
|
const showSeriesOverlay = computed(() => cardOverlays.value.includes('series') && seriesLine.value != null)
|
|
const hasProgress = computed(() => props.book.readingProgress != null && props.book.readingProgress > 0)
|
|
const showProgressBar = computed(() => cardOverlays.value.includes('progress-bar') && hasProgress.value)
|
|
const showProgressPill = computed(() => cardOverlays.value.includes('progress-pill') && hasProgress.value)
|
|
const showFormatOverlay = computed(() => cardOverlays.value.includes('format') && primaryFile.value?.format != null)
|
|
const showRatingOverlay = computed(() => cardOverlays.value.includes('rating') && props.book.rating != null)
|
|
const showNewOverlay = computed(() => {
|
|
if (!cardOverlays.value.includes('new')) return false
|
|
const addedAt = new Date(props.book.addedAt)
|
|
return Date.now() - addedAt.getTime() < 7 * 24 * 60 * 60 * 1000
|
|
})
|
|
|
|
const ratingColor = computed(() => {
|
|
const r = Math.round(props.book.rating ?? 0)
|
|
if (r <= 1) return '#dc2626'
|
|
if (r === 2) return '#ea580c'
|
|
if (r === 3) return '#ca8a04'
|
|
if (r === 4) return '#65a30d'
|
|
return '#059669'
|
|
})
|
|
|
|
const coverLoaded = ref(false)
|
|
const coverFailed = ref(false)
|
|
const isMissing = computed(() => props.book.status === 'missing')
|
|
|
|
watch(coverSrc, () => {
|
|
coverLoaded.value = false
|
|
coverFailed.value = false
|
|
})
|
|
|
|
function onMainImgRef(el: Element | ComponentPublicInstance | null) {
|
|
const img = el as HTMLImageElement | null
|
|
if (img?.complete && img.naturalWidth > 0) coverLoaded.value = true
|
|
}
|
|
|
|
function openFile(file: BookFileRef) {
|
|
router.push({
|
|
name: 'reader',
|
|
params: { bookId: props.book.id, fileId: file.id },
|
|
query: { format: file.format ?? 'epub' },
|
|
})
|
|
}
|
|
|
|
function handleCardClick(event: MouseEvent) {
|
|
if ((event.target as Element).closest('button')) return
|
|
if (props.selectionMode) {
|
|
emit('select', event)
|
|
return
|
|
}
|
|
if (primaryFile.value && !isMissing.value) openFile(primaryFile.value)
|
|
}
|
|
|
|
function openQuickView() {
|
|
emit('action', 'quick-view')
|
|
}
|
|
|
|
function openAuthorBrowse() {
|
|
if (!authorQuery.value) return
|
|
void router.push({ name: 'authors', query: { q: authorQuery.value } })
|
|
}
|
|
|
|
const showAuthorOnHover = computed(() => coverLoaded.value && !!authorLine.value)
|
|
const hoverTitleClampClass = computed(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
|
|
|
|
const { downloadFile, exportBooks } = useBookDownload()
|
|
|
|
function handleDownloadFile(file: BookFileRef) {
|
|
void downloadFile(file.id)
|
|
}
|
|
|
|
function handleExportAll() {
|
|
void exportBooks([props.book.id], true)
|
|
}
|
|
|
|
const { setStatus } = useBookStatus()
|
|
|
|
const localReadStatus = ref<ReadStatus | null>(props.book.readStatus?.status ?? null)
|
|
watch(
|
|
() => props.book.readStatus?.status,
|
|
(val) => {
|
|
localReadStatus.value = val ?? null
|
|
},
|
|
)
|
|
const showReadBadge = computed(
|
|
() => cardOverlays.value.includes('read-status') && localReadStatus.value != null && localReadStatus.value !== 'unread' && !props.selectionMode,
|
|
)
|
|
|
|
async function handleSetStatus(status: ReadStatus) {
|
|
const prev = localReadStatus.value
|
|
localReadStatus.value = status
|
|
try {
|
|
await setStatus(props.book.id, status)
|
|
} catch {
|
|
localReadStatus.value = prev
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="group flex flex-col @container"
|
|
:class="[selectionMode || (primaryFile && !isMissing) ? 'cursor-pointer' : 'cursor-default', selectionMode ? 'select-none' : '']"
|
|
@click="handleCardClick"
|
|
>
|
|
<!-- Cover -->
|
|
<div
|
|
class="relative w-full rounded-sm overflow-hidden shadow-md transition-[box-shadow,transform,ring] duration-150 will-change-transform"
|
|
:class="[isMissing ? '' : selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]']"
|
|
:style="[{ aspectRatio: coverAspectRatio }, !coverLoaded || coverFailed ? coverStyle : {}]"
|
|
>
|
|
<!-- Missing border overlay: mirror selected overlay pattern so border is never clipped/hidden -->
|
|
<div v-if="isMissing" class="absolute inset-0 z-30 pointer-events-none rounded-sm ring-2 ring-inset ring-amber-500" />
|
|
|
|
<!-- Blurred background fill for mismatched aspect ratios -->
|
|
<img
|
|
v-if="coverLoaded && !coverFailed"
|
|
:src="coverSrc"
|
|
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-90"
|
|
aria-hidden="true"
|
|
loading="lazy"
|
|
/>
|
|
|
|
<img
|
|
v-if="!coverFailed"
|
|
:ref="onMainImgRef"
|
|
:src="coverSrc"
|
|
class="absolute inset-0 w-full h-full object-contain"
|
|
:class="isMissing ? 'brightness-50' : ''"
|
|
loading="lazy"
|
|
decoding="async"
|
|
:alt="book.title ?? ''"
|
|
@load="coverLoaded = true"
|
|
@error="coverFailed = true"
|
|
/>
|
|
|
|
<!-- Series badge -->
|
|
<div v-if="showSeriesOverlay" class="absolute top-1.5 left-1.5 right-1.5 z-10 pointer-events-none">
|
|
<span
|
|
class="text-[8px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-black/60 line-clamp-1"
|
|
:style="{ color: coverStyle.color }"
|
|
>
|
|
{{ seriesLine }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- "New" dot - top-right accent indicator -->
|
|
<div v-if="showNewOverlay && !isMissing" class="absolute top-1.5 right-1.5 z-10 size-2 rounded-full bg-primary shadow-sm pointer-events-none" />
|
|
|
|
<!-- Read status icon - top-left -->
|
|
<div
|
|
v-if="showReadBadge && !isMissing"
|
|
class="absolute top-1.5 left-1.5 z-10 pointer-events-none group-hover:opacity-0 transition-opacity duration-150"
|
|
>
|
|
<component
|
|
:is="STATUS_ICONS[localReadStatus!]"
|
|
:size="14"
|
|
:class="[STATUS_COLORS[localReadStatus!], 'drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]']"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Bottom-left overlays: rating dots + progress pill -->
|
|
<div
|
|
v-if="(showRatingOverlay || showProgressPill) && !selectionMode"
|
|
class="absolute bottom-1.5 left-1.5 z-10 flex flex-col gap-0.5 items-start pointer-events-none"
|
|
>
|
|
<div v-if="showProgressPill">
|
|
<span
|
|
v-if="book.readingProgress === 100"
|
|
class="flex items-center gap-0.5 text-[8px] font-semibold px-1.5 py-0.5 rounded bg-green-600/90 text-white"
|
|
>
|
|
<Check :size="8" />
|
|
</span>
|
|
<span v-else class="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/60" :style="{ color: coverStyle.color }">
|
|
{{ Math.floor(book.readingProgress!) }}%
|
|
</span>
|
|
</div>
|
|
<div v-if="showRatingOverlay" class="relative flex items-center justify-center size-5 group-hover:opacity-0 transition-opacity duration-150">
|
|
<Star class="size-5" :style="{ fill: ratingColor, color: ratingColor }" />
|
|
<span class="absolute text-[7px] font-black text-white leading-none" style="margin-top: 1px">
|
|
{{ Math.round(book.rating!) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Format badge - bottom-right -->
|
|
<div
|
|
v-if="showFormatOverlay && !selectionMode"
|
|
class="absolute bottom-1.5 right-1.5 z-10 group-hover:opacity-0 transition-opacity duration-150 pointer-events-none"
|
|
>
|
|
<span
|
|
class="text-[8px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded text-white"
|
|
:style="{ backgroundColor: getFormatColor(primaryFile!.format!) + 'cc' }"
|
|
>
|
|
{{ primaryFile!.format!.toUpperCase() }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Audiobook badge - bottom-left, distinct from series/read-status badges at top-left -->
|
|
<div
|
|
v-if="isAudiobook && !selectionMode"
|
|
class="absolute bottom-5 left-1.5 z-10 group-hover:opacity-0 transition-opacity duration-150 pointer-events-none"
|
|
>
|
|
<span class="flex items-center gap-0.5 bg-black/60 text-white rounded px-1.5 py-0.5">
|
|
<Headphones class="size-2.5" />
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Reading progress bar - bottom edge -->
|
|
<div
|
|
v-if="showProgressBar && !selectionMode"
|
|
class="absolute bottom-0 left-0 z-10 h-0.75 transition-all duration-300"
|
|
:class="book.readingProgress === 100 ? 'bg-green-500/80' : 'bg-primary/70'"
|
|
:style="{ width: `${book.readingProgress}%` }"
|
|
/>
|
|
|
|
<!-- Selection checkbox overlay -->
|
|
<div
|
|
v-if="selectionMode"
|
|
class="absolute inset-0 z-30 pointer-events-none rounded-sm"
|
|
:class="selected ? 'bg-primary/20 ring-2 ring-inset ring-primary' : ''"
|
|
>
|
|
<div
|
|
class="absolute top-1.5 left-1.5 h-5 w-5 rounded flex items-center justify-center transition-colors"
|
|
:class="selected ? 'bg-primary' : 'bg-black/40 border border-white/50'"
|
|
>
|
|
<Check v-if="selected" class="text-primary-foreground" :size="12" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Missing badge -->
|
|
<div v-if="isMissing" class="absolute top-1.5 right-1.5 z-20">
|
|
<span class="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-amber-600/95 text-white">
|
|
<TriangleAlert class="size-2.5 shrink-0" />
|
|
Missing
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Title + author (no-cover fallback, always visible when cover absent) -->
|
|
<div v-if="!coverLoaded" class="absolute bottom-0 left-0 right-0 p-2 bg-linear-to-t from-black/60 to-transparent">
|
|
<p class="text-xs font-bold leading-tight" :class="hoverTitleClampClass" :style="{ color: coverStyle.color }">
|
|
{{ book.title ?? '-' }}
|
|
</p>
|
|
<button
|
|
v-if="authorLine"
|
|
class="text-[10px] mt-0.5 opacity-80 truncate hover:underline block w-full text-left"
|
|
:style="{ color: coverStyle.color }"
|
|
@click.stop="openAuthorBrowse"
|
|
>
|
|
{{ authorLine }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Refresh spinner overlay -->
|
|
<Transition name="fade">
|
|
<div v-if="anyRefreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/50">
|
|
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Hover overlay -->
|
|
<div
|
|
v-if="!selectionMode"
|
|
class="absolute inset-0 flex flex-col p-2 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
|
@click.stop="handleCardClick"
|
|
>
|
|
<!-- Top row: Quick View -->
|
|
<div class="shrink-0 flex justify-end">
|
|
<button class="p-1 rounded bg-primary/70 hover:bg-primary transition-colors text-white" @click="openQuickView">
|
|
<PanelRight class="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 min-h-0" />
|
|
|
|
<!-- Bottom: title/author -->
|
|
<div class="shrink-0 flex flex-col pr-10">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<p v-if="coverLoaded" class="text-xs font-semibold text-white leading-tight min-w-0 flex-1" :class="hoverTitleClampClass">
|
|
{{ book.title ?? '-' }}
|
|
</p>
|
|
<div v-else class="flex-1" />
|
|
</div>
|
|
|
|
<div v-if="showAuthorOnHover" class="min-w-0">
|
|
<button class="text-[10px] text-white/70 truncate hover:underline text-left block w-full" @click.stop="openAuthorBrowse">
|
|
{{ authorLine }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Kebab menu anchored to lower-right -->
|
|
<div class="absolute bottom-2 right-2 z-20">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger as-child>
|
|
<button class="px-1 py-2 rounded-md bg-black/40 hover:bg-white/30 transition-colors text-white shrink-0">
|
|
<MoreVertical class="size-3.5" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem v-if="openableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
|
|
<BookOpen class="size-4 mr-2" />
|
|
Open
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub v-else-if="openableFiles.length > 1 && !isMissing">
|
|
<DropdownMenuSubTrigger>
|
|
<BookOpen class="size-4 mr-2" />
|
|
Open
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="openFile(file)">
|
|
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
|
|
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
|
|
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
<!-- Download submenu -->
|
|
<DropdownMenuItem
|
|
v-if="hasPermission('library_download') && openableFiles.length === 1 && primaryFile"
|
|
@click="handleDownloadFile(primaryFile)"
|
|
>
|
|
<Download class="size-4 mr-2" />
|
|
Download
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub v-else-if="hasPermission('library_download') && openableFiles.length > 1">
|
|
<DropdownMenuSubTrigger>
|
|
<Download class="size-4 mr-2" />
|
|
Download
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="handleDownloadFile(file)">
|
|
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
|
|
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
|
|
<span v-if="file.role === 'primary' && !isMultiTrackAudio" 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 v-if="hasPermission('library_edit_metadata')" />
|
|
<DropdownMenuSub v-if="hasPermission('library_edit_metadata')">
|
|
<DropdownMenuSubTrigger>
|
|
<Pencil class="size-4 mr-2" />
|
|
Metadata
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })">
|
|
<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 :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
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<component
|
|
:is="STATUS_ICONS[localReadStatus ?? 'unread']"
|
|
class="size-4 mr-2"
|
|
:class="STATUS_COLORS[localReadStatus ?? 'unread']"
|
|
/>
|
|
Set Status
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="handleSetStatus(opt.value)">
|
|
<component :is="STATUS_ICONS[opt.value]" class="size-4 mr-2" :class="STATUS_COLORS[opt.value]" />
|
|
{{ opt.label }}
|
|
<Check v-if="localReadStatus === opt.value" class="size-3 ml-auto text-primary" />
|
|
</DropdownMenuItem>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuItem v-if="hasPermission('email_send')" @click="showSendDialog = true">
|
|
<Send class="size-4 mr-2" />
|
|
Send via Email
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
v-if="hasPermission('library_delete_books')"
|
|
class="text-destructive focus:text-destructive"
|
|
@click="emit('action', 'delete')"
|
|
>
|
|
<Trash2 class="size-4 mr-2" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<SendBookDialog
|
|
v-if="showSendDialog"
|
|
:open="showSendDialog"
|
|
:book-ids="[book.id]"
|
|
:book-files="book.files"
|
|
:book-title="book.title ?? undefined"
|
|
@update:open="showSendDialog = $event"
|
|
/>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|