diff --git a/client/package.json b/client/package.json index f2811014c..3f54d1e17 100644 --- a/client/package.json +++ b/client/package.json @@ -23,20 +23,22 @@ "format:check": "prettier --check src/" }, "dependencies": { + "@bookorbit/types": "workspace:*", "@fontsource-variable/fraunces": "^5.2.9", "@fontsource-variable/inter": "^5.2.8", - "@bookorbit/types": "workspace:*", "@tailwindcss/vite": "^4.2.4", + "@tanstack/vue-table": "^8.21.3", + "@tanstack/vue-virtual": "^3.13.24", "@types/howler": "^2.2.12", - "@vueuse/core": "^14.2.1", + "@vueuse/core": "^14.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dompurify": "^3.4.1", + "dompurify": "^3.4.2", "driver.js": "^1.4.0", "echarts": "^6.0.0", "howler": "^2.2.4", "lucide-vue-next": "^1.0.0", - "pdfjs-dist": "^5.6.205", + "pdfjs-dist": "^5.7.284", "pinia": "^3.0.4", "reka-ui": "^2.9.6", "socket.io-client": "^4.8.3", @@ -47,7 +49,7 @@ "vue-echarts": "8.1.0-beta.2", "vue-router": "^5.0.6", "vue-sonner": "^2.0.9", - "vue-virtual-scroller": "^3.0.0" + "vue-virtual-scroller": "^3.0.2" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", @@ -58,16 +60,16 @@ "@vitest/coverage-v8": "^4.1.5", "@vitest/eslint-plugin": "^1.6.16", "@vue/eslint-config-typescript": "^14.7.0", - "@vue/test-utils": "^2.4.8", + "@vue/test-utils": "^2.4.10", "@vue/tsconfig": "^0.9.1", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-oxlint": "~1.61.0", + "eslint-plugin-oxlint": "~1.62.0", "eslint-plugin-vue": "~10.9.0", "jiti": "^2.6.1", - "jsdom": "^29.0.2", + "jsdom": "^29.1.1", "npm-run-all2": "^8.0.4", - "oxlint": "~1.61.0", + "oxlint": "~1.62.0", "prettier": "3.8.3", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", diff --git a/client/src/components/AppLayout.vue b/client/src/components/AppLayout.vue index f41277c46..dfbed603b 100644 --- a/client/src/components/AppLayout.vue +++ b/client/src/components/AppLayout.vue @@ -13,6 +13,7 @@ const themeStore = useThemeStore() const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '') const BOOK_ROUTE_NAMES = new Set(['book-detail']) +const TABLE_VIEW_ROUTE_NAMES = new Set(['library', 'smartScope', 'collection', 'series-detail']) const viewKey = computed(() => { const name = String(route.name) if (BOOK_ROUTE_NAMES.has(name)) return name @@ -33,7 +34,13 @@ const viewKey = computed(() => {
-
+
diff --git a/client/src/components/SelectionActionBar.vue b/client/src/components/SelectionActionBar.vue index 1109b123f..c6dc0e1e6 100644 --- a/client/src/components/SelectionActionBar.vue +++ b/client/src/components/SelectionActionBar.vue @@ -3,6 +3,7 @@ import { computed, ref, useSlots, watch } from 'vue' import { BookOpen, Download, + FileSpreadsheet, FolderMinus, FolderPlus, ImageDown, @@ -18,12 +19,14 @@ import { Unlock, X, } from 'lucide-vue-next' +import InputWithSuggestions from '@/components/ui/InputWithSuggestions.vue' +import { usePublisherSearch, useSeriesNameSearch, useLanguageSearch } from '@/features/book/composables/useMetadataFieldSearch' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { usePermissions } from '@/features/auth/composables/usePermissions' import { STATUS_ICONS, STATUS_OPTIONS } from '@/features/book/composables/useBookStatus' import type { ReadStatus } from '@bookorbit/types' -import type { InFlightOp } from '@/features/book/composables/useBookBulkActions' +import type { BulkEditableField, BulkEditableValue, InFlightOp } from '@/features/book/composables/useBookBulkActions' export type ExportScope = 'primary' | 'all' | 'audio' @@ -53,31 +56,55 @@ const emit = defineEmits<{ 'remove-from-collection': [] edit: [] send: [] - export: [scope: ExportScope] + download: [scope: ExportScope] + 'export-metadata': [] 'refresh-metadata': [] 're-extract-cover': [] 'set-status': [status: ReadStatus] 'set-rating': [rating: number | null] 'edit-tags': [] + 'set-field': [field: BulkEditableField, value: BulkEditableValue] 'lock-metadata': [locked: boolean] delete: [] exit: [] }>() const { hasPermission, isDemoRestrictedAccount } = usePermissions() +const { search: searchPublisher } = usePublisherSearch() +const { search: searchSeriesName } = useSeriesNameSearch() +const { search: searchLanguage } = useLanguageSearch() const confirmingDelete = ref(false) const deleteInput = ref('') const exportMenuOpen = ref(false) const ratingMenuOpen = ref(false) -const moreMenuOpen = ref(false) +const fieldMenuOpen = ref(false) +const bulkField = ref('publisher') +const fieldValue = ref('') const slots = useSlots() const hasCustomContent = computed(() => Boolean(slots.content)) const canBulkActions = computed(() => !isDemoRestrictedAccount.value) +const canDownload = computed(() => hasPermission('library_download') && canBulkActions.value) +const canEditMetadata = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value) +const canShowMoreMenu = computed(() => canDownload.value || canEditMetadata.value) +const numericFieldSelected = computed(() => bulkField.value === 'publishedYear') +const arrayFieldSelected = computed(() => ['authors', 'genres', 'tags', 'narrators'].includes(bulkField.value)) +const fieldValuePlaceholder = computed(() => (arrayFieldSelected.value ? 'Comma-separated (leave blank to clear)' : 'Leave blank to clear')) +const typeaheadSearchFn = computed<((q: string) => Promise) | null>(() => { + if (bulkField.value === 'seriesName') return searchSeriesName + if (bulkField.value === 'publisher') return searchPublisher + if (bulkField.value === 'language') return searchLanguage + return null +}) const canConfirmDelete = computed(() => props.count <= 50 || deleteInput.value === 'DELETE') +const canApplyFieldValue = computed(() => { + if (!numericFieldSelected.value) return true + const trimmed = fieldValue.value.trim() + return trimmed.length === 0 || !Number.isNaN(Number(trimmed)) +}) function onExport(scope: ExportScope) { - emit('export', scope) + emit('download', scope) exportMenuOpen.value = false } @@ -114,6 +141,56 @@ function unlockAll() { emit('lock-metadata', false) } +function openFieldEditor() { + if (props.count === 0) return + fieldMenuOpen.value = true +} + +function onEditTags() { + if (props.count === 0) return + emit('edit-tags') +} + +function onRefreshMetadata() { + if (props.count === 0) return + emit('refresh-metadata') +} + +function onReExtractCover() { + if (props.count === 0) return + emit('re-extract-cover') +} + +function resetFieldEditor() { + fieldMenuOpen.value = false + bulkField.value = 'publisher' + fieldValue.value = '' +} + +function applyFieldEdit() { + if (!canApplyFieldValue.value) return + const trimmed = fieldValue.value.trim() + let value: BulkEditableValue + if (numericFieldSelected.value) { + value = trimmed ? Number(trimmed) : null + } else if (arrayFieldSelected.value) { + value = trimmed + ? [ + ...new Set( + trimmed + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ), + ] + : [] + } else { + value = trimmed || null + } + emit('set-field', bulkField.value, value) + resetFieldEditor() +} + watch( () => props.visible, (v) => { @@ -121,8 +198,8 @@ watch( confirmingDelete.value = false exportMenuOpen.value = false ratingMenuOpen.value = false - moreMenuOpen.value = false deleteInput.value = '' + resetFieldEditor() } }, ) @@ -155,7 +232,7 @@ watch( -