feat(books): add send via email action to details page

The details page now includes a 'Send via Email' button in the more options menu if the user has the email_send permission. Selecting it opens the SendBookDialog.

Closes #289
This commit is contained in:
neon
2026-06-09 03:10:50 +00:00
parent d9f3f31503
commit e07e3dac7b
2 changed files with 74 additions and 5 deletions
@@ -12,6 +12,7 @@ import {
MoreHorizontal,
Pencil,
RotateCcw,
Send,
Star,
Trash2,
TriangleAlert,
@@ -37,6 +38,7 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
import { useMetadataLocks } from '@/features/book/composables/useMetadataLocks'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import MetadataScoreBadge from '@/features/metadata-score/components/MetadataScoreBadge.vue'
import MetadataScoreBreakdown from '@/features/metadata-score/components/MetadataScoreBreakdown.vue'
@@ -84,6 +86,7 @@ const moreMenuOpen = ref(false)
const mobileMoreMenuOpen = ref(false)
const readMenuOpen = ref(false)
const mobileReadMenuOpen = ref(false)
const showSendDialog = ref(false)
const { weights: scoreWeights, fetchWeights } = useMetadataScoreWeights()
const { bookProgress: koreaderBookProgress, fetchBookProgress: fetchKoreaderProgress } = useKoreaderBookProgress()
@@ -746,6 +749,12 @@ function handleDeleteFromMenu() {
promptDelete(props.book.id)
}
function handleSendFromMenu() {
moreMenuOpen.value = false
mobileMoreMenuOpen.value = false
showSendDialog.value = true
}
function handleCoverLoad(ratio: number | null) {
coverLoaded.value = true
coverFailed.value = false
@@ -1133,14 +1142,27 @@ watch(
>
<Library class="size-3.5" />
</button>
<Popover v-if="hasPermission('library_delete_books')" :open="mobileMoreMenuOpen" @update:open="(v) => (mobileMoreMenuOpen = v)">
<Popover
v-if="hasPermission('library_delete_books') || hasPermission('email_send')"
:open="mobileMoreMenuOpen"
@update:open="(v) => (mobileMoreMenuOpen = v)"
>
<PopoverTrigger as-child>
<button class="flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-muted transition-colors">
<MoreHorizontal class="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-36 p-1" align="end">
<PopoverContent class="w-44 p-1" align="end">
<button
v-if="hasPermission('email_send')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
@click="handleSendFromMenu"
>
<Send class="size-3.5" />
Send via Email
</button>
<button
v-if="hasPermission('library_delete_books')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
@click="handleDeleteFromMenu"
>
@@ -1271,7 +1293,11 @@ watch(
>
<Library class="size-3.5" />
</button>
<Popover v-if="hasPermission('library_delete_books')" :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
<Popover
v-if="hasPermission('library_delete_books') || hasPermission('email_send')"
:open="moreMenuOpen"
@update:open="(v) => (moreMenuOpen = v)"
>
<PopoverTrigger 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"
@@ -1279,8 +1305,17 @@ watch(
<MoreHorizontal class="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-36 p-1" align="end">
<PopoverContent class="w-40 p-1" align="end">
<button
v-if="hasPermission('email_send')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
@click="handleSendFromMenu"
>
<Send class="size-3.5" />
Send via Email
</button>
<button
v-if="hasPermission('library_delete_books')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
@click="handleDeleteFromMenu"
>
@@ -1757,6 +1792,8 @@ watch(
@done="void loadSupplemental()"
/>
<SendBookDialog v-model:open="showSendDialog" :book-ids="[book.id]" :book-title="book.title ?? undefined" :book-files="book.files" />
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
<!-- Cover lightbox -->
@@ -8,6 +8,7 @@ import { useDisplaySettings } from '@/composables/useDisplaySettings'
const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
push: vi.fn<(to: unknown) => void>(),
hasPermission: vi.fn<(...args: unknown[]) => boolean>(),
}))
vi.mock('vue-router', async (importOriginal) => {
@@ -23,7 +24,7 @@ vi.mock('@/lib/api', () => ({
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => true }),
usePermissions: () => ({ hasPermission: mocks.hasPermission }),
}))
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
@@ -102,6 +103,9 @@ function mountDetails(book: BookDetail) {
BookCoverArtwork: false,
BookCoverSurface: false,
RouterLink: RouterLinkStub,
Popover: { template: '<div><slot /><slot name="content" /></div>' },
PopoverTrigger: { template: '<div><slot /></div>' },
PopoverContent: { template: '<div><slot /></div>' },
},
},
})
@@ -124,6 +128,8 @@ describe('DetailsTab cover surface', () => {
beforeEach(() => {
mocks.api.mockReset()
mocks.push.mockReset()
mocks.hasPermission.mockReset()
mocks.hasPermission.mockReturnValue(true)
mocks.api.mockImplementation(async (input) => {
const url = String(input)
@@ -246,4 +252,30 @@ describe('DetailsTab cover surface', () => {
])
expect(wrapper.text()).toContain('Author One, Author Two')
})
it('renders Send via Email button and opens dialog when user has email_send permission', async () => {
mocks.hasPermission.mockImplementation((perm) => perm === 'email_send')
const wrapper = mountDetails(makeBook())
await flushPromises()
const sendButtons = wrapper.findAll('button').filter((b) => b.text().includes('Send via Email'))
expect(sendButtons.length).toBeGreaterThan(0) // Desktop and mobile menus
// Find the stubbed SendBookDialog
const sendDialog = wrapper.findComponent({ name: 'SendBookDialog' })
expect(sendDialog.exists()).toBe(true)
expect(sendDialog.props('open')).toBe(false)
await sendButtons[0]!.trigger('click')
expect(sendDialog.props('open')).toBe(true)
})
it('does not render Send via Email button when user lacks email_send permission', async () => {
mocks.hasPermission.mockImplementation((perm) => perm !== 'email_send')
const wrapper = mountDetails(makeBook())
await flushPromises()
const sendButtons = wrapper.findAll('button').filter((b) => b.text().includes('Send via Email'))
expect(sendButtons.length).toBe(0)
})
})