Files
bookorbit/client/src/features/book/components/VirtualBookTable.vue
T
neon e7ccf6648c i18n(client): translate book filter, operator, and sort field labels
Replace hardcoded English filter, operator, and sort field labels with dynamic i18n lookup helpers that resolve against the active locale catalog.

Closes #807
2026-07-30 19:51:13 +00:00

1073 lines
42 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { formatNumber } from '@/i18n/formatters'
import { AlertTriangle, ArrowDown, ArrowUp, BookOpen, CheckCircle2, ChevronUp, Loader2, RotateCcw, X } from '@lucide/vue'
import { useTableColumns, COLUMN_DEFS, COLUMN_DEF_MAP, LOCK_ROW_COLUMN_DEF, type ColumnDef } from '@/features/book/composables/useTableColumns'
import { useTableCellEditor } from '@/features/book/composables/useTableCellEditor'
import { useTableLocks } from '@/features/book/composables/useTableLocks'
import { useTablePresets } from '@/features/book/composables/useTablePresets'
import { useTableContextMenu } from '@/features/book/composables/useTableContextMenu'
import { useTableScrollBehavior } from '@/features/book/composables/useTableScrollBehavior'
import { useTableResize } from '@/features/book/composables/useTableResize'
import { useTableDragReorder } from '@/features/book/composables/useTableDragReorder'
import { useTableKeyboard } from '@/features/book/composables/useTableKeyboard'
import { useTableSorting } from '@/features/book/composables/useTableSorting'
import { useTableQuickFilters } from '@/features/book/composables/useTableQuickFilters'
import { useTableCellHelpers } from '@/features/book/composables/useTableCellHelpers'
import { useTableCoverDialog } from '@/features/book/composables/useTableCoverDialog'
import KeyboardShortcutOverlay from './KeyboardShortcutOverlay.vue'
import { useAuthorSearch } from '@/features/book/composables/useAuthorSearch'
import { useGenreSearch, useTagSearch } from '@/features/book/composables/useTagSearch'
import { usePublisherSearch, useSeriesNameSearch, useLanguageSearch } from '@/features/book/composables/useMetadataFieldSearch'
import { useRefreshMetadata } from '@/features/book/composables/useRefreshMetadata'
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
import { useBookRefreshFeedback } from '@/features/book/composables/useBookRefreshFeedback'
import type { InFlightOp } from '@/features/book/composables/useBookBulkActions'
import { useRouter } from 'vue-router'
import { detectChangedColumns, mergeBookCardWithDetail } from '@/features/book/lib/book-card-mapper'
import { buildLockStateBookUpdate, mergeBookPatchWithLatest, sameLockFields } from '@/features/book/lib/table-row-state-sync'
import BookCoverDialog from './table/BookCoverDialog.vue'
import BookTableCellDispatcher from './table/BookTableCellDispatcher.vue'
import BookTableCollapsedSeriesCell from './table/BookTableCollapsedSeriesCell.vue'
import BookTableContextMenu from './table/BookTableContextMenu.vue'
import BookTableHeader from './table/BookTableHeader.vue'
import BookTableHeaderContextMenu from './table/BookTableHeaderContextMenu.vue'
import type { BookCard, Rule, SortSpec, TableLayoutState, TableViewType } from '@bookorbit/types'
import { BOOK_METADATA_LOCK_FIELDS } from '@bookorbit/types'
import { isBookPlaceholder, type BookSlot } from '@/features/book/composables/useBookWindow'
import { useNarratorSearch } from '@/features/book/composables/useNarratorSearch'
import { sortFieldLabel } from '@/features/book/lib/filter-labels'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useActiveCustomFields } from '@/features/book/composables/useActiveCustomFields'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
books: BookSlot[]
sort: SortSpec[]
viewType: TableViewType
libraryId?: number
selectionMode?: boolean
isSelected?: (id: number) => boolean
hasMore?: boolean
loading?: boolean
total?: number
sortable?: boolean
selectedCount?: number
filterActive?: boolean
initialized?: boolean
inFlight?: InFlightOp | null
}>(),
{ sortable: true, initialized: true },
)
type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
const emit = defineEmits<{
'update:sort': [value: SortSpec[]]
action: [book: BookCard, type: BookActionType]
select: [id: number, event: MouseEvent]
'update:book': [updated: BookCard]
'load-more': []
'select-all': [checked: boolean]
'enter-selection': []
'quick-filter': [rule: Rule]
'visible-range': [startIndex: number, endIndex: number]
'first-visible-index': [index: number]
}>()
const loadedBooks = computed(() => props.books.filter((slot): slot is BookCard => !isBookPlaceholder(slot)))
function rowBook(index: number): BookCard {
return props.books[index] as BookCard
}
function isPlaceholderRow(index: number): boolean {
const slot = props.books[index]
return slot === undefined || isBookPlaceholder(slot)
}
const { md } = useBreakpoints(breakpointsTailwind)
const isReadOnly = computed(() => !md.value)
const { tableDensity, tableZebraStriping } = useDisplaySettings()
const router = useRouter()
const { fields: allCustomFields } = useActiveCustomFields()
// In a library view, only show fields enabled for that specific library.
// In collection/smart scope views (no single library), show all active fields.
const customFields = computed(() => {
const lid = props.libraryId
if (lid == null) return allCustomFields.value
return allCustomFields.value.filter((f) => f.enabledLibraryIds.includes(lid))
})
const {
layout,
visibleColumns,
allColumns,
toggleColumn,
setColumnOrder,
setColumnWidth,
setLayout,
pinColumn,
unpinColumn,
resetLayout,
combinedColumnMap,
} = useTableColumns(props.viewType, customFields)
const editor = useTableCellEditor()
const locks = useTableLocks()
const tablePresets = useTablePresets(
props.viewType,
COLUMN_DEFS.map((column) => column.id),
)
const { search: searchAuthors } = useAuthorSearch()
const { search: searchGenres } = useGenreSearch()
const { search: searchTags } = useTagSearch()
const { search: searchNarrators } = useNarratorSearch()
const { search: searchPublisher } = usePublisherSearch()
const { search: searchSeriesName } = useSeriesNameSearch()
const { search: searchLanguage } = useLanguageSearch()
const { refreshWithFeedback } = useRefreshMetadata()
const { isRefreshing } = useRefreshingBooks()
const refreshFeedback = useBookRefreshFeedback()
const { getSortDir, handleColumnSort, removeSortField } = useTableSorting(
() => props.sort,
() => props.sortable ?? true,
(sort) => emit('update:sort', sort),
)
const { getQuickFilterOptions, buildQuickFilterRule } = useTableQuickFilters(props.viewType)
const { getCellValue, isCellLocked, isCellReadOnly, isMandatoryFieldEmpty, isBookFileMissing, getPinnedCellBackground } = useTableCellHelpers(
locks,
() => isReadOnly.value,
() => combinedColumnMap.value,
)
const { coverDialogBook, handleCoverClick, handleCoverDialogUpdateBook } = useTableCoverDialog(
() => props.selectionMode ?? false,
(updated) => emit('update:book', updated),
() => loadedBooks.value,
)
function getChipsSearchFn(colId: string): (q: string) => Promise<string[]> {
if (colId === 'authors') return searchAuthors
if (colId === 'genres') return searchGenres
if (colId === 'tags') return searchTags
if (colId === 'narrators') return searchNarrators
return () => Promise.resolve([])
}
function getChipsLinkFn(colId: string): ((chip: string) => string | null) | undefined {
if (colId === 'authors') return (name) => `/authors?q=${encodeURIComponent(name)}`
return undefined
}
function getChipsActionFn(colId: string): ((chip: string) => void) | undefined {
if (props.viewType !== 'library') return undefined
if (colId === 'genres') {
return (name) => emit('quick-filter', { type: 'rule', field: 'genre', operator: 'includesAny', value: [name] })
}
if (colId === 'tags') {
return (name) => emit('quick-filter', { type: 'rule', field: 'tag', operator: 'includesAny', value: [name] })
}
return undefined
}
function getTextCellOpenLink(book: BookCard, colId: string): string | null {
if (colId === 'title') return `/book/${book.id}`
if (colId === 'seriesName' && book.seriesId != null) return `/series/${book.seriesId}`
return null
}
const rowPaddingClass = computed(() => {
if (tableDensity.value === 'compact') return 'py-1'
if (tableDensity.value === 'roomy') return 'py-2.5'
return 'py-1.5'
})
function getTextCellOpenLinkLabel(colId: string): string | null {
if (colId === 'title') return t('book.tableView.openBookDetails')
if (colId === 'seriesName') return t('book.tableView.openSeries')
return null
}
function getTextSearchFn(colId: string): ((q: string) => Promise<string[]>) | undefined {
if (colId === 'publisher') return searchPublisher
if (colId === 'seriesName') return searchSeriesName
if (colId === 'language') return searchLanguage
return undefined
}
const metadataInFlight = computed(() => (props.inFlight?.label === 'Refreshing metadata' ? props.inFlight : null))
const metadataFailed = computed(() => metadataInFlight.value?.failed ?? 0)
const metadataRemaining = computed(() => {
if (!metadataInFlight.value) return 0
return Math.max(metadataInFlight.value.total - metadataInFlight.value.processed, 0)
})
function getRowFeedback(bookId: number) {
return refreshFeedback.getFeedback(bookId)
}
function isRowRefreshing(bookId: number): boolean {
return getRowFeedback(bookId)?.state === 'refreshing' || isRefreshing(bookId)
}
function isCellChanged(bookId: number, colId: string): boolean {
return refreshFeedback.isCellChanged(bookId, colId)
}
function getRowFeedbackMessage(bookId: number): string {
return getRowFeedback(bookId)?.message ?? t('book.tableView.metadataRefreshFailed')
}
async function retryMetadataRefresh(book: BookCard) {
if (isRefreshing(book.id)) return
refreshFeedback.markRefreshing(book.id)
const updated = await refreshWithFeedback(book.id)
if (updated) {
const merged = mergeBookCardWithDetail(book, updated)
refreshFeedback.markSuccess(book.id, detectChangedColumns(book, merged))
emit('update:book', merged)
return
}
refreshFeedback.markFailed(book.id)
}
// Init lock state for books and prune stale entries
watch(
loadedBooks,
(books) => {
for (const book of books) {
locks.initBook(book.id, book.lockedFields)
}
locks.pruneStaleEntries(new Set(books.map((b) => b.id)))
},
{ immediate: true },
)
const displayColumns = computed(() => [LOCK_ROW_COLUMN_DEF, ...visibleColumns.value])
const currentLayout = computed<TableLayoutState>(() => ({
columnOrder: [...layout.value.columnOrder],
hiddenColumns: [...layout.value.hiddenColumns],
columnWidths: { ...layout.value.columnWidths },
...(layout.value.pinnedColumns ? { pinnedColumns: { ...layout.value.pinnedColumns } } : {}),
}))
const pinnedLeftOffsets = computed(() => {
const offsets = new Map<string, number>()
let left = 0
for (const column of displayColumns.value) {
if (column.pinned === 'left' || column.id === 'lockRow') {
offsets.set(column.id, left)
left += column.defaultWidth
}
}
return offsets
})
const statusAnchorColumnId = computed<string | null>(() => {
const visibleIds = displayColumns.value.map((column) => column.id)
if (visibleIds.includes('title')) return 'title'
if (visibleIds.includes('authors')) return 'authors'
return null
})
const scrollContainerRef = ref<HTMLDivElement | null>(null)
const { contextMenuBook, contextMenuPosition, openContextMenu, closeContextMenu } = useTableContextMenu()
const { isScrolled, showScrollTop, onScroll, scrollToTop } = useTableScrollBehavior(scrollContainerRef)
// Extracted composables
const {
resizingColumnId: _resizingColumnId,
isResizableCol: _isResizableCol,
startResize,
autoFitColumn,
autoFitAllColumns,
} = useTableResize(scrollContainerRef, displayColumns, setColumnWidth, isReadOnly)
void _resizingColumnId
void _isResizableCol
const {
dragSourceColId,
dropTargetColId,
dropSide,
isDraggableCol: _isDraggableCol,
handleColDragStart,
handleColDragOver,
handleColDragLeave,
handleColDrop,
handleColDragEnd,
} = useTableDragReorder(allColumns, setColumnOrder)
void _isDraggableCol
// Column header context menu
const headerCtxColumn = ref<ColumnDef | null>(null)
const headerCtxPosition = ref<{ x: number; y: number } | null>(null)
const headerCtxSortDir = computed<'asc' | 'desc' | null>(() => {
if (!headerCtxColumn.value?.sortField) return null
return getSortDir(headerCtxColumn.value.sortField)
})
function handleHeaderContextMenu(event: MouseEvent, col: ColumnDef) {
if (isReadOnly.value || col.id === 'lockRow') return
event.preventDefault()
headerCtxColumn.value = col
headerCtxPosition.value = { x: event.clientX, y: event.clientY }
}
function handleHeaderCtxSortAsc() {
if (!headerCtxColumn.value?.sortField) return
emit('update:sort', [{ field: headerCtxColumn.value.sortField, dir: 'asc' }])
}
function handleHeaderCtxSortDesc() {
if (!headerCtxColumn.value?.sortField) return
emit('update:sort', [{ field: headerCtxColumn.value.sortField, dir: 'desc' }])
}
function handleHeaderCtxClearSort() {
if (!headerCtxColumn.value?.sortField) return
removeSortField(headerCtxColumn.value.sortField)
}
function handleHeaderCtxHideColumn() {
if (!headerCtxColumn.value) return
toggleColumn(headerCtxColumn.value.id)
}
function handleHeaderCtxAutoFitAll() {
autoFitAllColumns()
}
function handleHeaderCtxAutoFit() {
if (!headerCtxColumn.value) return
autoFitColumn(headerCtxColumn.value.id)
}
function handleHeaderCtxPinLeft() {
if (!headerCtxColumn.value) return
pinColumn(headerCtxColumn.value.id, 'left')
}
function handleHeaderCtxPinRight() {
if (!headerCtxColumn.value) return
pinColumn(headerCtxColumn.value.id, 'right')
}
function handleHeaderCtxUnpin() {
if (!headerCtxColumn.value) return
unpinColumn(headerCtxColumn.value.id)
}
function handleHeaderCtxQuickFilter(key: string) {
if (!headerCtxColumn.value) return
const rule = buildQuickFilterRule(headerCtxColumn.value.id, key)
if (!rule) return
emit('quick-filter', rule)
}
function closeHeaderContextMenu() {
headerCtxColumn.value = null
headerCtxPosition.value = null
}
// Checkbox selection helpers (over loaded rows only)
const allBooksSelected = computed(() => {
if (loadedBooks.value.length === 0 || !props.isSelected) return false
return loadedBooks.value.every((b) => props.isSelected!(b.id))
})
const someBooksSelected = computed(() => {
if (loadedBooks.value.length === 0 || !props.isSelected) return false
const someSelected = loadedBooks.value.some((b) => props.isSelected!(b.id))
return someSelected && !allBooksSelected.value
})
function handleSelectAllToggle(event: Event) {
const checked = (event.target as HTMLInputElement).checked
emit('select-all', checked)
}
function handleCheckboxClick(bookId: number, event: MouseEvent) {
if (!props.selectionMode) {
emit('enter-selection')
}
emit('select', bookId, event)
}
// Sort strip
const activeSorts = computed(() =>
props.sort.map((s) => ({
field: s.field,
dir: s.dir,
label: sortFieldLabel(s.field),
})),
)
// Skeleton rows for initial loading
const SKELETON_ROW_COUNT = 12
const rowEstimate = computed(() => {
if (tableDensity.value === 'compact') return 36
if (tableDensity.value === 'roomy') return 52
return 44
})
const virtualizer = useVirtualizer(
computed(() => ({
count: props.books.length,
getScrollElement: () => scrollContainerRef.value,
estimateSize: () => rowEstimate.value,
overscan: 10,
})),
)
const virtualItems = computed(() => virtualizer.value.getVirtualItems())
const totalSize = computed(() => virtualizer.value.getTotalSize())
const isLoadingMore = ref(false)
watch(virtualItems, (items) => {
if (!items.length) return
emit('visible-range', items[0]!.index, items[items.length - 1]!.index)
const firstVisible = Math.max(0, Math.floor((virtualizer.value.scrollOffset ?? 0) / rowEstimate.value))
emit('first-visible-index', Math.min(props.books.length - 1, firstVisible))
if (!props.hasMore || props.loading || isLoadingMore.value) return
const last = items[items.length - 1]!
if (last.index >= props.books.length - 15) {
isLoadingMore.value = true
emit('load-more')
}
})
watch(
() => props.loading,
(loading) => {
if (!loading) isLoadingMore.value = false
},
)
// Keyboard navigation
const { focusedRowIndex, focusedColIndex, isFocusedCell, handleTableKeydown } = useTableKeyboard({
books: () => props.books as BookCard[],
displayColumns,
activeCellKey: editor.activeCellKey,
selectionMode: () => props.selectionMode ?? false,
isReadOnly: () => isReadOnly.value,
virtualizer,
isCellReadOnly,
onActivate: handleActivate,
onSelect: (id, event) => emit('select', id, event),
onCopyRow: () => {},
columnMapGetter: () => combinedColumnMap.value,
})
const shortcutOverlayOpen = ref(false)
function handleKeydownWithShortcuts(event: KeyboardEvent) {
if (event.key === '?' && !editor.activeCellKey.value) {
event.preventDefault()
shortcutOverlayOpen.value = !shortcutOverlayOpen.value
return
}
if (shortcutOverlayOpen.value) return
handleTableKeydown(event)
}
function shouldPreserveTargetFocus(target: EventTarget | null): boolean {
const targetElement = target instanceof Element ? target : target instanceof Node ? target.parentElement : null
if (!targetElement) return false
return Boolean(
targetElement.closest(
[
'input',
'textarea',
'select',
'button',
'a',
'[role="button"]',
'[role="textbox"]',
'[role="combobox"]',
'[contenteditable="true"]',
'[data-cell-activator="true"]',
'[tabindex]:not([tabindex="-1"])',
].join(', '),
),
)
}
function focusTableCell(rowIndex: number, colIndex: number, event?: MouseEvent, bookId?: number, colId?: string) {
focusedRowIndex.value = rowIndex
focusedColIndex.value = colIndex
if (bookId != null && colId && editor.isActive(bookId, colId)) return
if (shouldPreserveTargetFocus(event?.target ?? null)) return
scrollContainerRef.value?.focus({ preventScroll: true })
}
onMounted(() => {
scrollContainerRef.value?.addEventListener('scroll', onScroll, { passive: true })
})
onUnmounted(() => {
scrollContainerRef.value?.removeEventListener('scroll', onScroll)
})
let savedTableRowIndex = 0
onDeactivated(() => {
const offset = virtualizer.value.scrollOffset ?? scrollContainerRef.value?.scrollTop ?? 0
savedTableRowIndex = Math.max(0, Math.floor(offset / rowEstimate.value))
})
onActivated(() => {
void nextTick(() => {
requestAnimationFrame(() => {
if (savedTableRowIndex > 0) {
virtualizer.value.scrollToIndex(Math.min(savedTableRowIndex, props.books.length - 1), { align: 'start' })
}
})
})
})
// Bump tbody key on sort change to trigger enter animation
watch(
() => props.sort,
() => {
nextTick(() => {
if (scrollContainerRef.value) scrollContainerRef.value.scrollTop = 0
})
},
{ deep: true },
)
// Editable column IDs in display order (for Tab navigation)
const editableColumnIds = computed<string[]>(() => visibleColumns.value.filter((c) => c.isEditable).map((c) => c.id))
function handleActivate(book: BookCard, colId: string) {
if (isBookPlaceholder(book as BookSlot)) return
if (isReadOnly.value || props.selectionMode) return
const col = displayColumns.value.find((c) => c.id === colId)
if (!col || isCellReadOnly(book, col)) return
editor.activateCell(book.id, colId, getCellValue(book, colId))
}
function handleSave(book: BookCard, colId: string, newValue: unknown) {
editor.saveCell(
book.id,
colId,
newValue,
(patch) => {
const updated = mergeBookPatchWithLatest(loadedBooks.value, book, patch)
emit('update:book', updated)
},
book,
)
}
function handleCancel(bookId: number, colId: string) {
editor.cancelCellIfActive(bookId, colId)
}
function handleNavigate(book: BookCard, colId: string, direction: 'next' | 'prev' | 'rowUp' | 'rowDown') {
if (direction === 'rowUp' || direction === 'rowDown') {
editor.navigateRow(direction === 'rowDown' ? 'down' : 'up', loadedBooks.value, book.id, colId)
return
}
editor.navigateCell(direction, editableColumnIds.value, book, colId)
}
function handleRowClick(book: BookCard, e: MouseEvent) {
if (props.selectionMode) {
emit('select', book.id, e)
return
}
if (book.collapsedSeries) {
if (book.seriesId != null) router.push({ name: 'series-detail', params: { seriesId: book.seriesId } })
}
}
function emitLockStateUpdate(book: BookCard, nextFields: BookCard['lockedFields']) {
const updated = buildLockStateBookUpdate(loadedBooks.value, book, nextFields)
if (!updated) return
emit('update:book', updated)
}
async function handleToggleCellLock(book: BookCard, colId: string) {
const lockField = COLUMN_DEF_MAP.get(colId)?.lockField
if (!lockField) return
const beforeFields = [...locks.getFields(book.id)]
await locks.toggleField(book.id, lockField)
const afterFields = [...locks.getFields(book.id)]
if (sameLockFields(beforeFields, afterFields)) return
emitLockStateUpdate(book, afterFields)
}
async function handleLockAllRow(book: BookCard) {
const beforeFields = [...locks.getFields(book.id)]
await locks.lockAll(book.id)
const afterFields = [...locks.getFields(book.id)]
if (sameLockFields(beforeFields, afterFields)) return
emitLockStateUpdate(book, afterFields)
}
async function handleUnlockAllRow(book: BookCard) {
const beforeFields = [...locks.getFields(book.id)]
await locks.unlockAll(book.id)
const afterFields = [...locks.getFields(book.id)]
if (sameLockFields(beforeFields, afterFields)) return
emitLockStateUpdate(book, afterFields)
}
function isRowFullyLocked(book: BookCard): boolean {
return getRowLockedFieldCount(book) >= BOOK_METADATA_LOCK_FIELDS.length
}
function getRowLockedFieldCount(book: BookCard): number {
return locks.getFields(book.id).length
}
function handleRowContextMenu(event: MouseEvent, book: BookCard): void {
if (props.selectionMode) return
openContextMenu(event, book)
}
function handleContextMenuAction(book: BookCard, type: BookActionType): void {
closeContextMenu()
emit('action', book, type)
}
function applyPreset(layoutState: TableLayoutState, sort?: SortSpec[]): void {
setLayout(layoutState)
if (sort && sort.length > 0) emit('update:sort', sort)
}
function saveCurrentPreset(name: string): void {
tablePresets.savePreset(name, currentLayout.value, props.sort)
}
function scrollToIndex(index: number) {
virtualizer.value.scrollToIndex(index, { align: 'start' })
}
defineExpose({
allColumns,
currentLayout,
toggleColumn,
setColumnOrder,
resetLayout,
applyPreset,
saveCurrentPreset,
scrollToIndex,
deletePreset: tablePresets.deletePreset,
renamePreset: tablePresets.renamePreset,
duplicatePreset: tablePresets.duplicatePreset,
togglePresetFavorite: tablePresets.toggleFavorite,
importPresetBackup: tablePresets.importPresets,
allPresets: tablePresets.allPresets,
autoFitAllColumns,
})
</script>
<template>
<div class="relative flex-1 min-h-0 flex flex-col overflow-hidden rounded-md border border-border">
<!-- Screen-reader live region for dynamic announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<span v-if="selectionMode && selectedCount != null && selectedCount > 0">{{
t('book.tableView.booksSelected', { count: selectedCount })
}}</span>
<span v-if="loading && initialized">{{ t('book.tableView.loadingMoreBooks') }}</span>
</div>
<!-- Sort strip: visible when multiple sorts are active -->
<div v-if="activeSorts.length > 1" class="shrink-0 flex items-center gap-1.5 border-b border-border/60 bg-muted/30 px-3 py-1">
<span class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground shrink-0">{{ t('book.tableView.sort') }}</span>
<div class="flex flex-wrap items-center gap-1">
<span
v-for="(s, idx) in activeSorts"
:key="s.field"
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
>
<span class="flex h-3.5 w-3.5 items-center justify-center rounded-full bg-primary/20 text-[9px] font-bold">{{ idx + 1 }}</span>
{{ s.label }}
<ArrowUp v-if="s.dir === 'asc'" :size="10" />
<ArrowDown v-else :size="10" />
<button class="ml-0.5 rounded-full p-0.5 hover:bg-primary/20 transition-colors" @click="removeSortField(s.field)">
<X :size="9" />
</button>
</span>
</div>
</div>
<div v-if="metadataInFlight" class="shrink-0 flex items-center justify-between border-b border-border/60 bg-primary/5 px-3 py-1">
<div class="flex items-center gap-1.5 text-xs font-medium text-primary">
<Loader2 :size="12" class="animate-spin" />
<span>{{ t('book.tableView.refreshingMetadata', { processed: metadataInFlight.processed, total: metadataInFlight.total }) }}</span>
</div>
<span v-if="metadataFailed > 0" class="text-xs font-medium text-amber-700 dark:text-amber-400">{{
t('book.tableView.failedCount', { count: metadataFailed })
}}</span>
<span v-else class="text-xs text-muted-foreground">{{ t('book.tableView.remainingCount', { count: metadataRemaining }) }}</span>
</div>
<div v-if="isReadOnly" class="shrink-0 border-b border-border/60 bg-amber-500/8 px-3 py-1.5 text-xs text-amber-700 dark:text-amber-300">
{{ t('book.tableView.readOnlyNotice') }}
</div>
<div ref="scrollContainerRef" class="relative flex-1 min-h-0 overflow-auto" tabindex="0" @keydown="handleKeydownWithShortcuts">
<table role="grid" class="w-full border-collapse text-sm" style="table-layout: fixed">
<BookTableHeader
:display-columns="displayColumns"
:sort="props.sort"
:is-scrolled="isScrolled"
:loading="props.loading ?? false"
:selection-mode="props.selectionMode ?? false"
:all-books-selected="allBooksSelected"
:some-books-selected="someBooksSelected"
:is-read-only="isReadOnly"
:pinned-left-offsets="pinnedLeftOffsets"
:drag-source-col-id="dragSourceColId"
:drop-target-col-id="dropTargetColId"
:drop-side="dropSide"
:sortable="props.sortable ?? true"
@select-all="handleSelectAllToggle"
@column-sort="handleColumnSort"
@header-context-menu="handleHeaderContextMenu"
@col-drag-start="handleColDragStart"
@col-drag-over="handleColDragOver"
@col-drag-leave="handleColDragLeave"
@col-drop="handleColDrop"
@col-drag-end="handleColDragEnd"
@resize-start="startResize"
@auto-fit-column="autoFitColumn"
/>
<!-- Skeleton loading rows -->
<tbody v-if="!initialized && loading" role="rowgroup">
<tr v-for="i in SKELETON_ROW_COUNT" :key="`skeleton-${i}`" role="row" class="border-b border-border/50">
<td v-if="selectionMode" role="gridcell" class="px-2 align-middle" :class="rowPaddingClass" :style="{ width: '36px', minWidth: '36px' }">
<div class="mx-auto h-4 w-4 rounded bg-muted animate-pulse" />
</td>
<td
v-for="col in displayColumns"
:key="col.id"
role="gridcell"
class="overflow-hidden px-2 align-middle"
:class="rowPaddingClass"
:style="{ width: `${col.defaultWidth}px`, minWidth: `${col.minWidth}px` }"
>
<div v-if="col.cellType === 'cover'" class="h-9 w-9 rounded-sm bg-muted animate-pulse" />
<div v-else-if="col.cellType === 'rating'" class="flex items-center gap-0.5">
<div v-for="s in 5" :key="s" class="h-3 w-3 rounded-full bg-muted animate-pulse" />
</div>
<div v-else-if="col.cellType === 'progress'" class="h-1.5 w-full rounded-full bg-muted animate-pulse" />
<div v-else-if="col.cellType === 'actions'" class="h-5 w-5 rounded bg-muted animate-pulse" />
<div v-else class="h-4 rounded bg-muted animate-pulse" :style="{ width: `${40 + ((i * 17 + col.defaultWidth) % 60)}%` }" />
</td>
</tr>
</tbody>
<tbody v-else class="tbody-enter" role="rowgroup">
<tr v-if="virtualItems.length > 0 && virtualItems[0]!.start > 0" role="row">
<td role="gridcell" :colspan="displayColumns.length + (selectionMode ? 1 : 0)" :style="{ height: `${virtualItems[0]!.start}px` }" />
</tr>
<template v-for="vItem in virtualItems" :key="String(vItem.key)">
<tr v-if="isPlaceholderRow(vItem.index)" role="row" class="border-b border-border/50" :data-row-index="vItem.index">
<td v-if="selectionMode" role="gridcell" class="px-2" :class="rowPaddingClass" :style="{ width: '36px', minWidth: '36px' }" />
<td
v-for="col in displayColumns"
:key="col.id"
role="gridcell"
class="overflow-hidden px-2 align-middle"
:class="rowPaddingClass"
:style="{ width: `${col.defaultWidth}px`, minWidth: `${col.minWidth}px` }"
>
<div class="h-3.5 max-w-32 animate-pulse rounded bg-foreground/5" data-testid="book-row-skeleton" />
</td>
</tr>
<tr
v-else
role="row"
:aria-label="rowBook(vItem.index).title ?? undefined"
class="group border-b border-border/50 transition-colors"
:class="[
selectionMode && isSelected?.(rowBook(vItem.index).id) ? 'bg-primary/8' : 'hover:bg-muted/40',
selectionMode ? 'cursor-pointer' : '',
isBookFileMissing(rowBook(vItem.index)) ? 'bg-destructive/5' : '',
isRowRefreshing(rowBook(vItem.index).id) ? 'opacity-85' : '',
focusedRowIndex === vItem.index && !editor.activeCellKey.value ? 'ring-1 ring-inset ring-primary/40' : '',
tableZebraStriping && vItem.index % 2 === 1 && !(selectionMode && isSelected?.(rowBook(vItem.index).id)) ? 'bg-muted/20' : '',
]"
@click="handleRowClick(rowBook(vItem.index), $event)"
@contextmenu.prevent="(event) => handleRowContextMenu(event, rowBook(vItem.index))"
>
<!-- Checkbox cell -->
<td
v-if="selectionMode"
role="gridcell"
class="overflow-hidden px-2 bg-background"
:class="rowPaddingClass"
:style="{ width: '36px', minWidth: '36px', position: 'sticky', left: '0px', zIndex: 20 }"
>
<div class="flex items-center justify-center">
<input
type="checkbox"
class="accent-primary h-3.5 w-3.5 cursor-pointer"
:checked="isSelected?.(rowBook(vItem.index).id) ?? false"
@click.stop="handleCheckboxClick(rowBook(vItem.index).id, $event)"
/>
</div>
</td>
<td
v-for="(col, colIdx) in displayColumns"
:key="col.id"
:data-col-id="col.id"
:data-row-index="vItem.index"
role="gridcell"
class="relative overflow-hidden px-2 align-middle"
:class="[
rowPaddingClass,
col.pinned === 'right' ? 'border-l border-border/60' : '',
col.pinned === null ? (isMandatoryFieldEmpty(rowBook(vItem.index), col.id) ? 'bg-amber-500/5' : '') : '',
isCellChanged(rowBook(vItem.index).id, col.id) ? 'book-cell--changed' : '',
isFocusedCell(vItem.index, colIdx) && !editor.isActive(rowBook(vItem.index).id, col.id)
? 'outline outline-2 outline-primary/50 -outline-offset-2 rounded-sm'
: '',
]"
:style="{
width: `${col.defaultWidth}px`,
minWidth: `${col.minWidth}px`,
...(col.pinned === 'left' || col.id === 'lockRow'
? {
position: 'sticky',
left: `${(selectionMode ? 36 : 0) + (pinnedLeftOffsets.get(col.id) ?? 0)}px`,
zIndex: col.id === 'lockRow' ? 20 : 2,
background: getPinnedCellBackground(
rowBook(vItem.index),
col.id,
selectionMode && (isSelected?.(rowBook(vItem.index).id) ?? false),
),
}
: {}),
...(col.pinned === 'right'
? {
position: 'sticky',
right: '0',
zIndex: 2,
background: getPinnedCellBackground(
rowBook(vItem.index),
col.id,
selectionMode && (isSelected?.(rowBook(vItem.index).id) ?? false),
),
}
: {}),
}"
@click="focusTableCell(vItem.index, colIdx, $event, rowBook(vItem.index).id, col.id)"
>
<template v-if="rowBook(vItem.index).collapsedSeries">
<BookTableCollapsedSeriesCell :book="rowBook(vItem.index)" :col-id="col.id" />
</template>
<template v-else>
<BookTableCellDispatcher
:book="rowBook(vItem.index)"
:col-id="col.id"
:cell-type="col.cellType"
:has-lock-field="!!COLUMN_DEF_MAP.get(col.id)?.lockField"
:is-locked="isCellLocked(rowBook(vItem.index), col.id)"
:is-active="editor.isActive(rowBook(vItem.index).id, col.id)"
:is-read-only="isCellReadOnly(rowBook(vItem.index), col)"
:is-fully-locked="isRowFullyLocked(rowBook(vItem.index))"
:locked-field-count="getRowLockedFieldCount(rowBook(vItem.index))"
:selection-mode="props.selectionMode ?? false"
:always-show-open-link-icon="isReadOnly"
:value="getCellValue(rowBook(vItem.index), col.id)"
:search-fn="col.cellType === 'text' ? getTextSearchFn(col.id) : col.cellType === 'chips' ? getChipsSearchFn(col.id) : undefined"
:open-link="col.cellType === 'text' ? getTextCellOpenLink(rowBook(vItem.index), col.id) : undefined"
:open-link-label="col.cellType === 'text' ? getTextCellOpenLinkLabel(col.id) : undefined"
:link-fn="col.cellType === 'chips' ? getChipsLinkFn(col.id) : undefined"
:chip-action-fn="col.cellType === 'chips' ? getChipsActionFn(col.id) : undefined"
:allow-decimal="col.id === 'seriesIndex'"
@activate="handleActivate(rowBook(vItem.index), col.id)"
@save="(value) => handleSave(rowBook(vItem.index), col.id, value)"
@cancel="handleCancel(rowBook(vItem.index).id, col.id)"
@navigate="(direction) => handleNavigate(rowBook(vItem.index), col.id, direction)"
@cover-click="handleCoverClick(rowBook(vItem.index))"
@toggle-lock="handleToggleCellLock(rowBook(vItem.index), col.id)"
@lock-all="handleLockAllRow(rowBook(vItem.index))"
@unlock-all="handleUnlockAllRow(rowBook(vItem.index))"
@action="(type) => emit('action', rowBook(vItem.index), type)"
@update:book="(updated) => emit('update:book', updated)"
/>
<div
v-if="statusAnchorColumnId === col.id && getRowFeedback(rowBook(vItem.index).id)"
class="pointer-events-auto absolute right-1 top-1 z-30"
>
<span
v-if="getRowFeedback(rowBook(vItem.index).id)?.state === 'refreshing'"
class="inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/12 px-1.5 py-0.5 text-[10px] font-medium text-primary"
>
<Loader2 :size="10" class="animate-spin" />
{{ t('book.tableView.fetching') }}
</span>
<span
v-else-if="getRowFeedback(rowBook(vItem.index).id)?.state === 'success'"
class="inline-flex items-center gap-1 rounded-full border border-emerald-500/30 bg-emerald-500/12 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300"
>
<CheckCircle2 :size="10" />
{{ t('book.tableView.updated') }}
</span>
<span
v-else
class="inline-flex items-center gap-1 rounded-full border border-destructive/40 bg-destructive/10 px-1 py-0.5 text-[10px] font-medium text-destructive"
>
<AlertTriangle :size="10" />
{{ t('book.tableView.failed') }}
<button
class="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full hover:bg-destructive/20"
:title="getRowFeedbackMessage(rowBook(vItem.index).id)"
@click.stop="retryMetadataRefresh(rowBook(vItem.index))"
>
<RotateCcw :size="8" />
</button>
</span>
</div>
</template>
</td>
</tr>
</template>
<tr v-if="virtualItems.length > 0" role="row">
<td
role="gridcell"
:colspan="displayColumns.length + (selectionMode ? 1 : 0)"
:style="{ height: `${totalSize - virtualItems[virtualItems.length - 1]!.end}px` }"
/>
</tr>
</tbody>
</table>
</div>
<div
v-if="books.length === 0 && !loading"
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground"
>
<BookOpen :size="36" class="opacity-30" />
<p class="text-sm">{{ t('book.tableView.noBooks') }}</p>
</div>
<!-- Scroll to top FAB -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
leave-active-class="transition-all duration-150 ease-in"
enter-from-class="opacity-0 translate-y-2"
leave-to-class="opacity-0 translate-y-2"
>
<button
v-if="showScrollTop"
class="absolute bottom-10 right-4 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md hover:bg-primary/90 transition-colors"
:aria-label="t('book.tableView.scrollToTop')"
@click="scrollToTop"
>
<ChevronUp :size="16" />
</button>
</Transition>
<!-- Enhanced status bar -->
<div class="shrink-0 flex items-center justify-between border-t border-border/60 bg-muted/40 px-3 py-1 text-xs text-muted-foreground">
<div class="flex items-center gap-2">
<span v-if="selectionMode && selectedCount">
{{ t('book.tableView.selectedStatus', { count: formatNumber(selectedCount) }) }}
<span class="text-muted-foreground">/ </span>
</span>
<span v-if="filterActive" class="flex items-center gap-1 text-primary">
<span class="h-1.5 w-1.5 rounded-full bg-primary" />
{{ t('book.tableView.filtered') }}
</span>
<span v-if="hasMore">{{ t('book.tableView.loadedStatus', { loaded: formatNumber(books.length), total: formatNumber(total ?? 0) }) }}</span>
<span v-else>{{ t('book.tableView.bookCount', { count: total ?? books.length }) }}</span>
</div>
<div class="flex items-center gap-3">
<button
class="inline-flex h-5 w-5 items-center justify-center rounded border border-border/70 bg-background text-[11px] font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
:title="t('book.tableView.keyboardShortcutsTitle')"
:aria-label="t('book.tableView.showKeyboardShortcuts')"
@click="shortcutOverlayOpen = true"
>
?
</button>
<span class="hidden lg:inline text-[11px] text-muted-foreground">{{ t('book.tableView.copyHint') }}</span>
<span v-if="loading" class="flex items-center gap-1">
<Loader2 :size="11" class="animate-spin" />
{{ t('common.loading') }}
</span>
</div>
</div>
<BookTableContextMenu
:book="contextMenuBook"
:position="contextMenuPosition"
@action="handleContextMenuAction"
@update:book="(updated) => emit('update:book', updated)"
@close="closeContextMenu"
/>
<BookTableHeaderContextMenu
v-if="headerCtxColumn"
:column="headerCtxColumn"
:position="headerCtxPosition"
:sort-dir="headerCtxSortDir"
:quick-filters="getQuickFilterOptions(headerCtxColumn.id)"
@sort-asc="handleHeaderCtxSortAsc"
@sort-desc="handleHeaderCtxSortDesc"
@clear-sort="handleHeaderCtxClearSort"
@quick-filter="handleHeaderCtxQuickFilter"
@hide-column="handleHeaderCtxHideColumn"
@auto-fit="handleHeaderCtxAutoFit"
@auto-fit-all="handleHeaderCtxAutoFitAll"
@pin-left="handleHeaderCtxPinLeft"
@pin-right="handleHeaderCtxPinRight"
@unpin="handleHeaderCtxUnpin"
@close="closeHeaderContextMenu"
/>
</div>
<BookCoverDialog :book="coverDialogBook" :read-only="isReadOnly" @close="coverDialogBook = null" @update:book="handleCoverDialogUpdateBook" />
<KeyboardShortcutOverlay v-model:open="shortcutOverlayOpen" />
</template>
<style scoped>
.tbody-enter {
animation: tbodyEnter 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes tbodyEnter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.book-cell--changed {
animation: cellChangedFlash 2.8s ease-out both;
}
@keyframes cellChangedFlash {
0% {
box-shadow: inset 0 0 0 999px color-mix(in oklch, oklch(0.696 0.17 162.48) 20%, transparent);
}
85% {
box-shadow: inset 0 0 0 999px color-mix(in oklch, oklch(0.696 0.17 162.48) 8%, transparent);
}
100% {
box-shadow: inset 0 0 0 999px transparent;
}
}
</style>