feat: add multi-book edit action to selection bar

This commit is contained in:
neon
2026-03-18 08:12:25 -06:00
parent 416952a3a0
commit d07b876b9c
6 changed files with 74 additions and 96 deletions
+43 -93
View File
@@ -1,10 +1,22 @@
<script setup lang="ts">
import { computed, ref, useSlots, watch } from 'vue'
import { Download, FolderMinus, FolderPlus, ImageDown, Mail, RefreshCw, Trash2, X } from 'lucide-vue-next'
import { Download, FolderMinus, FolderPlus, ImageDown, Mail, Pencil, RefreshCw, Trash2, X } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const ICON_SIZE = 17
const BTN_ICON = 'text-foreground/80 h-9 w-9 flex items-center justify-center rounded-full transition-colors'
const BTN_DISABLED = 'text-muted-foreground/60 cursor-not-allowed'
const BTN_PRIMARY = 'text-foreground hover:bg-primary hover:text-primary-foreground'
const BTN_MUTED = 'text-foreground hover:bg-muted'
const BTN_DESTRUCTIVE = 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
const BTN_TEXT_PRIMARY =
'flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors'
const BTN_TEXT_CANCEL = 'h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors'
const BTN_TEXT_DESTRUCTIVE =
'h-8 px-3 rounded-full text-sm font-medium text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors'
const DIVIDER = 'w-px h-5 bg-border mx-1 shrink-0'
const props = defineProps<{
count: number
visible: boolean
@@ -14,6 +26,7 @@ const props = defineProps<{
const emit = defineEmits<{
'add-to-collection': []
'remove-from-collection': []
edit: []
send: []
export: [allFormats: boolean]
'refresh-metadata': []
@@ -66,19 +79,13 @@ watch(
<slot name="content" :count="count" />
</template>
<template v-else-if="!confirmingDelete && !exportMenuOpen">
<!-- Count -->
<span class="px-2.5 py-0.5 text-sm font-semibold tabular-nums whitespace-nowrap rounded-full bg-primary/10 text-primary">{{ count }}</span>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div :class="DIVIDER" />
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/60 cursor-not-allowed'"
@click="emit('send')"
>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="emit('send')">
<Mail :size="ICON_SIZE" />
</button>
</TooltipTrigger>
@@ -87,12 +94,7 @@ watch(
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/60 cursor-not-allowed'"
@click="exportMenuOpen = true"
>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="exportMenuOpen = true">
<Download :size="ICON_SIZE" />
</button>
</TooltipTrigger>
@@ -101,12 +103,7 @@ watch(
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground/60 cursor-not-allowed'"
@click="emit('add-to-collection')"
>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="emit('add-to-collection')">
<FolderPlus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
@@ -115,32 +112,27 @@ watch(
<Tooltip v-if="inCollection">
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="
count > 0
? 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
: 'text-muted-foreground/60 cursor-not-allowed'
"
@click="emit('remove-from-collection')"
>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_DESTRUCTIVE : BTN_DISABLED]" @click="emit('remove-from-collection')">
<FolderMinus :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Remove from collection</TooltipContent>
</Tooltip>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<Tooltip>
<TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="emit('edit')">
<Pencil :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Edit metadata</TooltipContent>
</Tooltip>
<div :class="DIVIDER" />
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/60 cursor-not-allowed'"
@click="emit('refresh-metadata')"
>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]" @click="emit('refresh-metadata')">
<RefreshCw :size="ICON_SIZE" />
</button>
</TooltipTrigger>
@@ -149,46 +141,29 @@ watch(
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="count > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground/60 cursor-not-allowed'"
@click="emit('re-extract-cover')"
>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]" @click="emit('re-extract-cover')">
<ImageDown :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Re-extract cover from file</TooltipContent>
</Tooltip>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div :class="DIVIDER" />
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="count === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="
count > 0
? 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
: 'text-muted-foreground/60 cursor-not-allowed'
"
@click="confirmingDelete = true"
>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_DESTRUCTIVE : BTN_DISABLED]" @click="confirmingDelete = true">
<Trash2 :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Delete selected</TooltipContent>
</Tooltip>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div :class="DIVIDER" />
<Tooltip>
<TooltipTrigger as-child>
<button
class="h-9 w-9 flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="emit('exit')"
>
<button :class="[BTN_ICON, 'text-muted-foreground hover:text-foreground hover:bg-muted']" @click="emit('exit')">
<X :size="ICON_SIZE" />
</button>
</TooltipTrigger>
@@ -199,50 +174,25 @@ watch(
<template v-else-if="exportMenuOpen">
<span class="px-3 text-sm font-semibold text-foreground whitespace-nowrap">Export as ZIP:</span>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div :class="DIVIDER" />
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
@click="onExport(false)"
>
Primary only
</button>
<button :class="BTN_TEXT_PRIMARY" @click="onExport(false)">Primary only</button>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-full text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
@click="onExport(true)"
>
All formats
</button>
<button :class="BTN_TEXT_PRIMARY" @click="onExport(true)">All formats</button>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div :class="DIVIDER" />
<button
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="exportMenuOpen = false"
>
Cancel
</button>
<button :class="BTN_TEXT_CANCEL" @click="exportMenuOpen = false">Cancel</button>
</template>
<template v-else>
<span class="px-3 text-sm font-semibold text-destructive whitespace-nowrap">Delete {{ count }} book{{ count === 1 ? '' : 's' }}?</span>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div :class="DIVIDER" />
<button
class="h-8 px-3 rounded-full text-sm font-medium text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors"
@click="onConfirmDelete"
>
Delete
</button>
<button :class="BTN_TEXT_DESTRUCTIVE" @click="onConfirmDelete">Delete</button>
<button
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="confirmingDelete = false"
>
Cancel
</button>
<button :class="BTN_TEXT_CANCEL" @click="confirmingDelete = false">Cancel</button>
</template>
</TooltipProvider>
</div>
+9
View File
@@ -106,6 +106,14 @@ async function handleRemoveFromCollection() {
}
}
function handleEditSelected() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
setBookContext(ids, ids.length)
router.push({ name: 'book-edit', params: { bookId: ids[0] } })
exitSelectionMode()
}
function handleBookAction(book: BookCard, action: 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete') {
if (action === 'quick-view') {
quickViewBookId.value = book.id
@@ -167,6 +175,7 @@ watch(
@export="handleExport"
@add-to-collection="addToCollectionOpen = true"
@remove-from-collection="handleRemoveFromCollection"
@edit="handleEditSelected"
@refresh-metadata="handleBulkRefreshMetadata"
@re-extract-cover="handleBulkReExtractCover"
@delete="handleDeleteSelected"
+11 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ArrowUpDown, Bookmark, BookmarkCheck, BookOpen, Filter, Telescope, X } from 'lucide-vue-next'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
@@ -30,6 +30,7 @@ import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
import type { GroupRule, SortSpec } from '@projectx/types'
const route = useRoute()
const router = useRouter()
const { coverSize, gridGap, viewMode } = useDisplaySettings()
const { libraries } = useLibraries()
@@ -263,6 +264,14 @@ type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'de
const quickViewBookId = ref<number | null>(null)
const quickViewOpen = ref(false)
function handleEditSelected() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
setBookContext(ids, ids.length)
router.push({ name: 'book-edit', params: { bookId: ids[0] } })
exitSelectionMode()
}
function handleBookAction(book: BookCard, action: BookActionType) {
if (action === 'quick-view') {
quickViewBookId.value = book.id
@@ -481,6 +490,7 @@ function handleBookAction(book: BookCard, action: BookActionType) {
@send="sendBookOpen = true"
@export="handleExport"
@add-to-collection="addToCollectionOpen = true"
@edit="handleEditSelected"
@refresh-metadata="handleBulkRefreshMetadata"
@re-extract-cover="handleBulkReExtractCover"
@delete="handleDeleteSelected"
+9
View File
@@ -99,6 +99,14 @@ type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'de
const quickViewBookId = ref<number | null>(null)
const quickViewOpen = ref(false)
function handleEditSelected() {
const ids = [...selectedIds.value]
if (ids.length === 0) return
setBookContext(ids, ids.length)
router.push({ name: 'book-edit', params: { bookId: ids[0] } })
exitSelectionMode()
}
function handleBookAction(book: BookCard, action: BookActionType) {
if (action === 'quick-view') {
quickViewBookId.value = book.id
@@ -200,6 +208,7 @@ watch(
@send="sendBookOpen = true"
@export="handleExport"
@add-to-collection="addToCollectionOpen = true"
@edit="handleEditSelected"
@refresh-metadata="handleBulkRefreshMetadata"
@re-extract-cover="handleBulkReExtractCover"
@delete="handleDeleteSelected"
+1 -1
View File
@@ -8,7 +8,7 @@ const maxWidth = computed(() => (route.meta.maxWidth as string | undefined) ?? '
</script>
<template>
<div class="flex flex-col my-2 min-h-[calc(100%-1.25rem)] overflow-hidden rounded-xl border border-border/70 bg-card/40">
<div class="flex flex-col mt-2 min-h-[calc(100%-0.5rem)] overflow-hidden rounded-xl border border-border/70 bg-card/40">
<SettingsHeader />
<main class="flex-1 overflow-y-auto overflow-x-hidden">
<div class="px-6 py-6" :class="maxWidth">
+1 -1
View File
@@ -349,7 +349,7 @@ onUnmounted(() => {
<template>
<main class="flex-1" @dragover="onDragOver" @dragenter="onDragEnter" @dragleave="onDragLeave" @drop="onDrop">
<div class="flex flex-col gap-4 p-4 sm:p-6 max-w-8xl w-full">
<div class="flex flex-col gap-4 py-4 sm:py-6 max-w-8xl w-full">
<div class="flex items-center gap-2.5">
<div class="flex items-center justify-center size-9 rounded-lg bg-primary/10">
<PackageOpen class="size-4.5 text-primary" />