refactor(scanner): rename staging module to book bucket

This commit is contained in:
neon
2026-04-03 20:50:11 -06:00
parent af7df49b02
commit 0a58a87239
82 changed files with 1073 additions and 1094 deletions
+16 -16
View File
@@ -27,7 +27,7 @@ import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import BookUploadModal from '@/features/library/components/BookUploadModal.vue'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useStagingSummary } from '@/features/staging/composables/useStagingSummary'
import { useBookBucketSummary } from '@/features/book-bucket/composables/useBookBucketSummary'
import UserAvatar from '@/components/UserAvatar.vue'
const router = useRouter()
@@ -36,13 +36,13 @@ const { user, logout } = useAuth()
const { open: openChangePassword } = useChangePasswordDialog()
const { hasPermission } = usePermissions()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { summary: stagingSummary, fetchSummary: fetchStagingSummary, subscribe: subscribeStagingSummary } = useStagingSummary()
const { summary: bookBucketSummary, fetchSummary: fetchBookBucketSummary, subscribe: subscribeBookBucketSummary } = useBookBucketSummary()
const isStagingActive = computed(() => route.name === 'staging')
const isBookBucketActive = computed(() => route.name === 'book-bucket')
const isStatisticsActive = computed(() => route.name === 'statistics')
function navigateToStaging() {
router.push({ name: 'staging' })
function navigateToBookBucket() {
router.push({ name: 'book-bucket' })
}
function navigateToStatistics() {
@@ -140,9 +140,9 @@ function handleGlobalKeydown(e: KeyboardEvent) {
onMounted(() => {
window.addEventListener('keydown', handleGlobalKeydown)
if (hasPermission('staging_access')) {
fetchStagingSummary()
subscribeStagingSummary()
if (hasPermission('book_bucket_access')) {
fetchBookBucketSummary()
subscribeBookBucketSummary()
}
})
@@ -405,32 +405,32 @@ function formatBadgeClass(fmt: string): string {
<TooltipContent>Search</TooltipContent>
</Tooltip>
<!-- Group 1: Content (Staging, Statistics, Upload) -->
<!-- Group 1: Content (Book Bucket, Statistics, Upload) -->
<div class="hidden md:flex items-center gap-2.5">
<!-- Staging button -->
<Tooltip v-if="hasPermission('staging_access')">
<!-- Book Bucket button -->
<Tooltip v-if="hasPermission('book_bucket_access')">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="relative h-8 w-8 rounded-xl border transition-colors"
:class="
isStagingActive
isBookBucketActive
? 'border-primary/80 bg-primary/8 text-primary'
: 'border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground'
"
@click="navigateToStaging"
@click="navigateToBookBucket"
>
<PackageOpen :size="15" />
<span
v-if="stagingSummary.total > 0"
v-if="bookBucketSummary.total > 0"
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground tabular-nums leading-none"
>
{{ stagingSummary.total }}
{{ bookBucketSummary.total }}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>Staging</TooltipContent>
<TooltipContent>Book Bucket</TooltipContent>
</Tooltip>
<!-- Statistics button -->
+1 -1
View File
@@ -179,7 +179,7 @@ onUnmounted(() => stopUploadCompletedListener())
</SidebarHeader>
<SidebarContent>
<!-- Dashboard / Staging / Authors -->
<!-- Dashboard / Book Bucket / Authors -->
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
+1 -1
View File
@@ -27,7 +27,7 @@ const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [
},
{
label: 'Devices & Access',
permissions: [Permission.KoboSync, Permission.OpdsAccess, Permission.StagingAccess],
permissions: [Permission.KoboSync, Permission.OpdsAccess, Permission.BookBucketAccess],
},
{
label: 'Email',
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { X, Loader2 } from 'lucide-vue-next'
import type { StagingBulkEditResult, StagingMetadata } from '@projectx/types'
import type { BookBucketBulkEditResult, BookBucketMetadata } from '@projectx/types'
import { api } from '@/lib/api'
const props = defineProps<{
@@ -16,9 +16,9 @@ const emit = defineEmits<{
const loading = ref(false)
const error = ref<string | null>(null)
const result = ref<StagingBulkEditResult | null>(null)
const result = ref<BookBucketBulkEditResult | null>(null)
const FIELDS: { key: keyof StagingMetadata; label: string; type: 'text' | 'number' | 'array' }[] = [
const FIELDS: { key: keyof BookBucketMetadata; label: string; type: 'text' | 'number' | 'array' }[] = [
{ key: 'title', label: 'Title', type: 'text' },
{ key: 'authors', label: 'Authors', type: 'array' },
{ key: 'publisher', label: 'Publisher', type: 'text' },
@@ -45,7 +45,7 @@ async function submit() {
loading.value = true
error.value = null
const fields: Partial<StagingMetadata> = {}
const fields: Partial<BookBucketMetadata> = {}
for (const f of FIELDS) {
if (!enabledFields.has(f.key)) continue
const raw = values[f.key] ?? ''
@@ -63,7 +63,7 @@ async function submit() {
}
try {
const res = await api('/api/v1/staging/files/bulk-edit', {
const res = await api('/api/v1/book-bucket/files/bulk-edit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1,17 +1,18 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { BookOpen, FileText, Wand2, Pencil, X } from 'lucide-vue-next'
import type { StagingFile, StagingMetadata } from '@projectx/types'
import type { BookBucketFile, BookBucketMetadata } from '@projectx/types'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { formatBytes } from '@/lib/formatting'
import StagingStatusBadge from './StagingStatusBadge.vue'
import BookBucketStatusBadge from './BookBucketStatusBadge.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useLibraries } from '@/features/library/composables/useLibraries'
const props = withDefaults(
defineProps<{
items: StagingFile[]
items: BookBucketFile[]
loading: boolean
initialized: boolean
isSelected: (id: number) => boolean
selectAll: boolean
namePreviewByFileId?: Record<number, string>
@@ -19,34 +20,34 @@ const props = withDefaults(
}>(),
{
namePreviewByFileId: () => ({}),
emptyMessage: 'Upload files or drop them in the staging folder',
emptyMessage: 'Upload files or drop them in the Book Bucket folder',
},
)
defineEmits<{
select: [number]
selectAll: []
open: [StagingFile]
open: [BookBucketFile]
applyFetched: [number]
}>()
function backendCoverUrl(file: StagingFile): string {
return `/api/v1/staging/files/${file.id}/cover?v=${new Date(file.updatedAt).getTime()}`
function backendCoverUrl(file: BookBucketFile): string {
return `/api/v1/book-bucket/files/${file.id}/cover?v=${new Date(file.updatedAt).getTime()}`
}
function selectedCoverUrl(file: StagingFile): string | null {
function selectedCoverUrl(file: BookBucketFile): string | null {
const url = file.selectedMetadata?.coverUrl?.trim()
return url ? url : null
}
function newCoverUrl(file: StagingFile): string | null {
function newCoverUrl(file: BookBucketFile): string | null {
const selected = selectedCoverUrl(file)
if (selected) return selected
const fetched = file.fetchedMetadata?.coverUrl?.trim()
return fetched ? fetched : null
}
function currentCoverUrl(file: StagingFile): string {
function currentCoverUrl(file: BookBucketFile): string {
return backendCoverUrl(file)
}
@@ -92,31 +93,31 @@ function onLightboxCoverError(event: Event) {
img.style.display = 'none'
}
function displayTitle(file: StagingFile): string {
function displayTitle(file: BookBucketFile): string {
const meta = file.selectedMetadata ?? file.embeddedMetadata
return meta?.title ?? file.fileName
}
function displayAuthor(file: StagingFile): string {
function displayAuthor(file: BookBucketFile): string {
const meta = file.selectedMetadata ?? file.embeddedMetadata
return meta?.authors?.join(', ') ?? ''
}
function hasContent(m: StagingMetadata | null): boolean {
function hasContent(m: BookBucketMetadata | null): boolean {
if (!m) return false
return Object.values(m).some((v) => v !== undefined && v !== null && v !== '')
}
type MetadataState = 'edited' | 'fetched' | 'embedded' | 'none'
function metadataState(file: StagingFile): MetadataState {
function metadataState(file: BookBucketFile): MetadataState {
if (hasContent(file.selectedMetadata)) return 'edited'
if (hasContent(file.fetchedMetadata)) return 'fetched'
if (hasContent(file.embeddedMetadata)) return 'embedded'
return 'none'
}
function confidenceBadgeClass(file: StagingFile): string {
function confidenceBadgeClass(file: BookBucketFile): string {
const c = file.confidence
if (c === null || c === undefined) return 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
if (c >= 80) return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
@@ -136,7 +137,7 @@ function joinTargetPath(folderPath: string, fileName: string): string {
return `${base}${separator}${fileName}`
}
function targetSummary(file: StagingFile): string {
function targetSummary(file: BookBucketFile): string {
const lib = file.targetLibraryId != null ? libraries.value.find((l) => l.id === file.targetLibraryId) : null
const folder = file.targetFolderId != null ? lib?.folders.find((f) => f.id === file.targetFolderId) : null
const libLabel = lib?.name ?? (file.targetLibraryId != null ? 'Unknown library' : 'Unassigned')
@@ -156,7 +157,7 @@ function targetSummary(file: StagingFile): string {
<template>
<div class="rounded-xl border border-border bg-card overflow-hidden">
<div v-if="loading && !items.length" class="divide-y divide-border">
<div v-if="!initialized && loading && !items.length" data-test="book-bucket-loading-skeleton" class="divide-y divide-border">
<div v-for="n in 5" :key="n" class="flex items-center gap-3 px-4 py-3 animate-pulse">
<div class="size-4 rounded bg-muted" />
<div class="size-10 rounded-lg bg-muted shrink-0" />
@@ -168,11 +169,11 @@ function targetSummary(file: StagingFile): string {
</div>
</div>
<div v-else-if="items.length === 0" class="py-16 flex flex-col items-center gap-3 text-muted-foreground">
<div v-else-if="items.length === 0" data-test="book-bucket-empty-state" class="py-16 flex flex-col items-center gap-3 text-muted-foreground">
<div class="size-12 rounded-full bg-muted flex items-center justify-center">
<FileText class="size-6" />
</div>
<p class="text-sm font-medium text-foreground">No staged files</p>
<p class="text-sm font-medium text-foreground">No files in Book Bucket</p>
<p class="text-xs">{{ emptyMessage }}</p>
</div>
@@ -294,7 +295,7 @@ function targetSummary(file: StagingFile): string {
</div>
<div class="w-16 flex justify-center shrink-0">
<StagingStatusBadge :status="file.status" />
<BookBucketStatusBadge :status="file.status" />
</div>
</button>
</div>
@@ -1,28 +1,28 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { X, BookOpen, Check, Trash2, Sparkles, ArrowLeft, Wand2, AlertCircle } from 'lucide-vue-next'
import type { StagingFile, StagingMetadata, MetadataCandidate, MetadataSource, MetadataProviderKey } from '@projectx/types'
import StagingStatusBadge from './StagingStatusBadge.vue'
import type { BookBucketFile, BookBucketMetadata, MetadataCandidate, MetadataSource, MetadataProviderKey } from '@projectx/types'
import BookBucketStatusBadge from './BookBucketStatusBadge.vue'
import MetadataSearchPanel from '@/features/book/components/detail/tabs/MetadataSearchPanel.vue'
import MetadataDiffPanel from '@/features/book/components/detail/tabs/MetadataDiffPanel.vue'
import { useStagingDetail } from '../composables/useStagingDetail'
import { useBookBucketDetail } from '../composables/useBookBucketDetail'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useMetadataSearch } from '@/features/book/composables/useMetadataSearch'
import type { MetadataPatch } from '@/features/book/composables/useMetadataDiff'
import { formatBytes } from '@/lib/formatting'
const props = defineProps<{ file: StagingFile }>()
const props = defineProps<{ file: BookBucketFile }>()
const emit = defineEmits<{
close: []
discarded: []
updated: [StagingFile]
updated: [BookBucketFile]
}>()
const { saved, saveError, saveMetadata, setTarget, discardFile, coverUrl } = useStagingDetail()
const { saved, saveError, saveMetadata, setTarget, discardFile, coverUrl } = useBookBucketDetail()
const { libraries, fetchLibraries: fetchLibs } = useLibraries()
const meta = computed(() => props.file.selectedMetadata ?? props.file.embeddedMetadata ?? ({} as StagingMetadata))
const meta = computed(() => props.file.selectedMetadata ?? props.file.embeddedMetadata ?? ({} as BookBucketMetadata))
const metaView = ref<'editor' | 'search' | 'diff'>('editor')
const selectedCandidate = ref<MetadataCandidate | null>(null)
@@ -98,7 +98,7 @@ onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
function buildMetadataPatchFromForm(): Partial<StagingMetadata> {
function buildMetadataPatchFromForm(): Partial<BookBucketMetadata> {
return {
title: form.title || undefined,
subtitle: form.subtitle || undefined,
@@ -268,14 +268,14 @@ const hasFetchedMetadata = computed(() => {
return Object.values(f).some((v) => v !== undefined && v !== null && v !== '')
})
const backendStagingCoverUrl = computed(() => `${coverUrl(props.file.id)}?v=${new Date(props.file.updatedAt).getTime()}`)
const backendBookBucketCoverUrl = computed(() => `${coverUrl(props.file.id)}?v=${new Date(props.file.updatedAt).getTime()}`)
const normalizedSelectedCoverUrl = computed(() => selectedCoverUrl.value.trim())
const currentStagingCoverUrl = computed(() => normalizedSelectedCoverUrl.value || backendStagingCoverUrl.value)
const currentStagingCoverFallbackUrl = computed(() => (normalizedSelectedCoverUrl.value ? backendStagingCoverUrl.value : null))
const currentBookBucketCoverUrl = computed(() => normalizedSelectedCoverUrl.value || backendBookBucketCoverUrl.value)
const currentBookBucketCoverFallbackUrl = computed(() => (normalizedSelectedCoverUrl.value ? backendBookBucketCoverUrl.value : null))
function onCurrentStagingCoverError(event: Event) {
function onCurrentBookBucketCoverError(event: Event) {
const img = event.target as HTMLImageElement
const fallback = currentStagingCoverFallbackUrl.value
const fallback = currentBookBucketCoverFallbackUrl.value
if (!fallback) {
img.style.display = 'none'
return
@@ -342,18 +342,18 @@ onMounted(() => {
<div class="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0 pr-12">
<div class="relative size-12 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
<img
:src="currentStagingCoverUrl"
:src="currentBookBucketCoverUrl"
alt=""
class="size-full object-cover"
@load="($event.target as HTMLImageElement).style.display = ''"
@error="onCurrentStagingCoverError"
@error="onCurrentBookBucketCoverError"
/>
<BookOpen class="size-5 text-muted-foreground absolute" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate">{{ file.fileName }}</p>
<div class="flex items-center gap-2 mt-0.5">
<StagingStatusBadge :status="file.status" />
<BookBucketStatusBadge :status="file.status" />
<span class="text-xs text-muted-foreground uppercase">{{ file.format }}</span>
<span class="text-xs text-muted-foreground">{{ formatBytes(file.fileSize) }}</span>
</div>
@@ -585,7 +585,7 @@ onMounted(() => {
:filtered-results="diffSource === 'fetched' ? [selectedCandidate] : filteredResults"
:providers="providers"
:back-label="diffSource === 'fetched' ? 'Back' : 'Results'"
:current-cover-url="currentStagingCoverUrl"
:current-cover-url="currentBookBucketCoverUrl"
:provider-ids="(file.fetchedMetadataSources as any) ?? undefined"
@back="backFromDiff"
@apply="handleApply"
@@ -5,7 +5,7 @@ import { X, Check, AlertCircle, Copy, Loader2, ExternalLink, ChevronDown, FileTe
import { api } from '@/lib/api'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useStagingFinalize } from '../composables/useStagingFinalize'
import { useBookBucketFinalize } from '../composables/useBookBucketFinalize'
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
@@ -19,7 +19,7 @@ const emit = defineEmits<{
const router = useRouter()
const { libraries, fetchLibraries, refreshLibraries } = useLibraries()
const { result, loading, error, finalize, reset } = useStagingFinalize()
const { result, loading, error, finalize, reset } = useBookBucketFinalize()
const defaultLibraryId = ref<number | null>(null)
const defaultFolderId = ref<number | null>(null)
@@ -84,7 +84,7 @@ async function fetchNamePreview(libId: number | null) {
...props.selectionPayload,
...(requiresDefaultDestination.value && libId ? { defaultLibraryId: libId } : {}),
}
const res = await api('/api/v1/staging/files/preview-names', {
const res = await api('/api/v1/book-bucket/files/preview-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -103,7 +103,7 @@ async function fetchNamePreview(libId: number | null) {
async function fetchSelectionSummary() {
try {
const res = await api('/api/v1/staging/files/selection-summary', {
const res = await api('/api/v1/book-bucket/files/selection-summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(props.selectionPayload),
@@ -57,7 +57,7 @@ async function applyDestination() {
loading.value = true
error.value = null
try {
const res = await api('/api/v1/staging/files/set-target', {
const res = await api('/api/v1/book-bucket/files/set-target', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { Loader2 } from 'lucide-vue-next'
import type { StagingFileStatus } from '@projectx/types'
import type { BookBucketFileStatus } from '@projectx/types'
defineProps<{ status: StagingFileStatus }>()
defineProps<{ status: BookBucketFileStatus }>()
const styles: Record<StagingFileStatus, string> = {
const styles: Record<BookBucketFileStatus, string> = {
pending: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
extracting: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
fetching: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
@@ -12,7 +12,7 @@ const styles: Record<StagingFileStatus, string> = {
error: 'bg-red-500/15 text-red-600 dark:text-red-400',
}
const labels: Record<StagingFileStatus, string> = {
const labels: Record<BookBucketFileStatus, string> = {
pending: 'Pending',
extracting: 'Extracting',
fetching: 'Fetching',
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw, FolderPlus, Loader2 } from 'lucide-vue-next'
import type { StagingFileStatus } from '@projectx/types'
import type { BookBucketFileStatus } from '@projectx/types'
import { api } from '@/lib/api'
import { SUPPORTED_FORMATS_ACCEPT, useStagingUpload } from '../composables/useStagingUpload'
import { SUPPORTED_FORMATS_ACCEPT, useBookBucketUpload } from '../composables/useBookBucketUpload'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
defineProps<{
activeStatus: StagingFileStatus | undefined
activeStatus: BookBucketFileStatus | undefined
selectionCount: number
hasSelection: boolean
fetchedCount: number
@@ -15,7 +15,7 @@ defineProps<{
}>()
const emit = defineEmits<{
statusFilter: [StagingFileStatus | undefined]
statusFilter: [BookBucketFileStatus | undefined]
rescan: []
rescanError: []
retryFetch: []
@@ -28,7 +28,7 @@ const emit = defineEmits<{
applyFetched: []
}>()
const { files: uploadFiles, isUploading, addFiles, clearCompleted } = useStagingUpload()
const { files: uploadFiles, isUploading, addFiles, clearCompleted } = useBookBucketUpload()
const fileInput = ref<HTMLInputElement | null>(null)
const rescanning = ref(false)
const searchQuery = ref('')
@@ -58,7 +58,7 @@ onUnmounted(() => {
if (popoverTimer) clearTimeout(popoverTimer)
})
const tabs: { label: string; value: StagingFileStatus | undefined }[] = [
const tabs: { label: string; value: BookBucketFileStatus | undefined }[] = [
{ label: 'All', value: undefined },
{ label: 'Pending', value: 'pending' },
{ label: 'Ready', value: 'ready' },
@@ -81,7 +81,7 @@ function onFilesSelected(event: Event) {
async function rescan() {
rescanning.value = true
try {
const res = await api('/api/v1/staging/rescan', { method: 'POST' })
const res = await api('/api/v1/book-bucket/rescan', { method: 'POST' })
if (res.ok) {
emit('rescan')
} else {
@@ -0,0 +1,82 @@
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { BookBucketFile } from '@projectx/types'
import { ref } from 'vue'
import BookBucketFileList from '../BookBucketFileList.vue'
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({
libraries: ref([]),
fetchLibraries: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
}),
}))
function makeFile(overrides: Partial<BookBucketFile> = {}): BookBucketFile {
return {
id: 1,
fileName: 'example.epub',
fileSize: 1024,
format: 'epub',
status: 'pending',
embeddedMetadata: null,
selectedMetadata: null,
fetchedMetadata: null,
targetLibraryId: null,
targetFolderId: null,
confidence: null,
fetchedMetadataSources: null,
errorMessage: null,
metadataEditedAt: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
function mountList(props: Partial<InstanceType<typeof BookBucketFileList>['$props']> = {}) {
return shallowMount(BookBucketFileList, {
props: {
items: [],
loading: false,
initialized: true,
isSelected: () => false,
selectAll: false,
...props,
},
})
}
describe('BookBucketFileList', () => {
it('shows the skeleton only during the initial load', () => {
const wrapper = mountList({
loading: true,
initialized: false,
items: [],
})
expect(wrapper.find('[data-test="book-bucket-loading-skeleton"]').exists()).toBe(true)
expect(wrapper.find('[data-test="book-bucket-empty-state"]').exists()).toBe(false)
})
it('keeps the empty state visible while a later tab refresh is loading', () => {
const wrapper = mountList({
loading: true,
initialized: true,
items: [],
emptyMessage: 'No ready files',
})
expect(wrapper.find('[data-test="book-bucket-loading-skeleton"]').exists()).toBe(false)
expect(wrapper.get('[data-test="book-bucket-empty-state"]').text()).toContain('No ready files')
})
it('renders file rows after loading completes', () => {
const wrapper = mountList({
items: [makeFile()],
})
expect(wrapper.find('[data-test="book-bucket-loading-skeleton"]').exists()).toBe(false)
expect(wrapper.find('[data-test="book-bucket-empty-state"]').exists()).toBe(false)
expect(wrapper.text()).toContain('example.epub')
})
})
@@ -1,9 +1,9 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { StagingFile, StagingMetadata } from '@projectx/types'
import type { BookBucketFile, BookBucketMetadata } from '@projectx/types'
export function useStagingDetail() {
const file = ref<StagingFile | null>(null)
export function useBookBucketDetail() {
const file = ref<BookBucketFile | null>(null)
const loading = ref(false)
const saving = ref(false)
const saved = ref(false)
@@ -14,23 +14,23 @@ export function useStagingDetail() {
async function loadFile(id: number) {
loading.value = true
try {
const res = await api(`/api/v1/staging/files/${id}`)
const res = await api(`/api/v1/book-bucket/files/${id}`)
if (res.ok) file.value = await res.json()
} finally {
loading.value = false
}
}
async function saveMetadata(id: number, metadata: Partial<StagingMetadata>): Promise<StagingFile | null> {
async function saveMetadata(id: number, metadata: Partial<BookBucketMetadata>): Promise<BookBucketFile | null> {
saving.value = true
try {
const res = await api(`/api/v1/staging/files/${id}`, {
const res = await api(`/api/v1/book-bucket/files/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selectedMetadata: metadata }),
})
if (res.ok) {
const updated: StagingFile = await res.json()
const updated: BookBucketFile = await res.json()
file.value = updated
saveError.value = null
saved.value = true
@@ -49,19 +49,19 @@ export function useStagingDetail() {
}
}
function debouncedSave(id: number, metadata: Partial<StagingMetadata>) {
function debouncedSave(id: number, metadata: Partial<BookBucketMetadata>) {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => saveMetadata(id, metadata), 1000)
}
async function setTarget(id: number, libraryId: number | null, folderId: number | null): Promise<StagingFile | null> {
const res = await api(`/api/v1/staging/files/${id}`, {
async function setTarget(id: number, libraryId: number | null, folderId: number | null): Promise<BookBucketFile | null> {
const res = await api(`/api/v1/book-bucket/files/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetLibraryId: libraryId, targetFolderId: folderId }),
})
if (res.ok) {
const updated: StagingFile = await res.json()
const updated: BookBucketFile = await res.json()
file.value = updated
return updated
}
@@ -69,12 +69,12 @@ export function useStagingDetail() {
}
async function discardFile(id: number) {
await api(`/api/v1/staging/files/${id}`, { method: 'DELETE' })
await api(`/api/v1/book-bucket/files/${id}`, { method: 'DELETE' })
file.value = null
}
function coverUrl(id: number): string {
return `/api/v1/staging/files/${id}/cover`
return `/api/v1/book-bucket/files/${id}/cover`
}
return { file, loading, saving, saved, saveError, loadFile, saveMetadata, debouncedSave, setTarget, discardFile, coverUrl }
@@ -1,17 +1,18 @@
import { computed, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import type { StagingFile, StagingFilesPage, StagingFileStatus } from '@projectx/types'
import type { BookBucketFile, BookBucketFilesPage, BookBucketFileStatus } from '@projectx/types'
export type SortField = 'createdAt' | 'fileName' | 'format' | 'status' | 'fileSize'
export type SortOrder = 'asc' | 'desc'
export function useStagingFiles() {
const items = ref<StagingFile[]>([])
export function useBookBucketFiles() {
const items = ref<BookBucketFile[]>([])
const total = ref(0)
const loading = ref(false)
const initialized = ref(false)
const filters = reactive({
status: undefined as StagingFileStatus | undefined,
status: undefined as BookBucketFileStatus | undefined,
search: '',
page: 1,
limit: 20,
@@ -38,22 +39,24 @@ export function useStagingFiles() {
params.set('sort', filters.sort)
params.set('order', filters.order)
const res = await api(`/api/v1/staging/files?${params}`)
if (res.ok) {
const data: StagingFilesPage = await res.json()
if (reqId !== fetchReqSeq) return
items.value = data.items
total.value = data.total
loading.value = false
}
const res = await api(`/api/v1/book-bucket/files?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: BookBucketFilesPage = await res.json()
if (reqId !== fetchReqSeq) return
items.value = data.items
total.value = data.total
} catch {
// Keep the current list visible when a refresh fails.
} finally {
if (reqId === fetchReqSeq) {
loading.value = false
initialized.value = true
}
}
}
function setStatus(status: StagingFileStatus | undefined) {
function setStatus(status: BookBucketFileStatus | undefined) {
clearSelection()
filters.status = status
filters.page = 1
@@ -122,7 +125,13 @@ export function useStagingFiles() {
const hasSelection = computed(() => selectionCount.value > 0)
function getSelectionPayload(): { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: StagingFileStatus; search?: string } {
function getSelectionPayload(): {
fileIds?: number[]
selectAll?: boolean
excludedIds?: number[]
status?: BookBucketFileStatus
search?: string
} {
if (selectAll.value) {
return {
selectAll: true,
@@ -138,6 +147,7 @@ export function useStagingFiles() {
items,
total,
loading,
initialized,
filters,
pageCount,
fetchFiles,
@@ -1,9 +1,9 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { StagingFinalizeResult, StagingFinalizeOverride } from '@projectx/types'
import type { BookBucketFinalizeResult, BookBucketFinalizeOverride } from '@projectx/types'
export function useStagingFinalize() {
const result = ref<StagingFinalizeResult | null>(null)
export function useBookBucketFinalize() {
const result = ref<BookBucketFinalizeResult | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
@@ -15,14 +15,14 @@ export function useStagingFinalize() {
search?: string
defaultLibraryId?: number
defaultFolderId?: number
overrides?: StagingFinalizeOverride[]
overrides?: BookBucketFinalizeOverride[]
}) {
loading.value = true
error.value = null
result.value = null
try {
const res = await api('/api/v1/staging/finalize', {
const res = await api('/api/v1/book-bucket/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -1,13 +1,13 @@
import { ref } from 'vue'
import type { StagingStatistics } from '@projectx/types'
import type { BookBucketStatistics } from '@projectx/types'
import { api } from '@/lib/api'
export function useStagingStatistics() {
const statistics = ref<StagingStatistics | null>(null)
export function useBookBucketStatistics() {
const statistics = ref<BookBucketStatistics | null>(null)
async function fetchStatistics() {
try {
const res = await api('/api/v1/staging/statistics')
const res = await api('/api/v1/book-bucket/statistics')
if (res.ok) statistics.value = await res.json()
} catch {
statistics.value = null
@@ -1,9 +1,9 @@
import { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import { api, getAccessToken } from '@/lib/api'
import type { StagingSummary } from '@projectx/types'
import type { BookBucketSummary } from '@projectx/types'
const summary = ref<StagingSummary>({ pending: 0, ready: 0, error: 0, total: 0 })
const summary = ref<BookBucketSummary>({ pending: 0, ready: 0, error: 0, total: 0 })
const loading = ref(false)
const socketConnected = ref(true)
let socket: Socket | null = null
@@ -14,7 +14,7 @@ let loaded = false
function requestSummary(markLoading: boolean): Promise<void> {
if (fetchPromise) return fetchPromise
if (markLoading) loading.value = true
fetchPromise = api('/api/v1/staging/summary')
fetchPromise = api('/api/v1/book-bucket/summary')
.then(async (res) => {
if (res.ok) {
summary.value = await res.json()
@@ -37,7 +37,7 @@ async function restFetchSummary() {
function getSocket(): Socket {
if (!socket) {
socket = io('/staging', {
socket = io('/book-bucket', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
reconnection: true,
@@ -45,7 +45,7 @@ function getSocket(): Socket {
reconnectionDelayMax: 10000,
})
socket.on('staging:summary', (data: StagingSummary) => {
socket.on('book-bucket:summary', (data: BookBucketSummary) => {
summary.value = data
for (const fn of changeListeners) fn()
})
@@ -62,7 +62,7 @@ function getSocket(): Socket {
return socket
}
export function useStagingSummary() {
export function useBookBucketSummary() {
async function fetchSummary(force = false): Promise<void> {
if (!force && loaded) return
return requestSummary(true)
@@ -72,10 +72,10 @@ export function useStagingSummary() {
getSocket()
}
function onStagingChange(fn: () => void) {
function onBookBucketChange(fn: () => void) {
changeListeners.add(fn)
return () => changeListeners.delete(fn)
}
return { summary, loading, socketConnected, fetchSummary, subscribe, onStagingChange }
return { summary, loading, socketConnected, fetchSummary, subscribe, onBookBucketChange }
}
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { getAccessToken } from '@/lib/api'
import type { StagingFile } from '@projectx/types'
import type { BookBucketFile } from '@projectx/types'
export const SUPPORTED_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2']
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
@@ -14,7 +14,7 @@ export interface UploadItem {
status: FileUploadStatus
progress: number
error?: string
stagingFile?: StagingFile
bookBucketFile?: BookBucketFile
}
const CONCURRENCY = 3
@@ -32,7 +32,7 @@ function uploadSingle(item: UploadItem): Promise<void> {
formData.append('file', item.file)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/v1/staging/upload')
xhr.open('POST', '/api/v1/book-bucket/upload')
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
@@ -46,7 +46,7 @@ function uploadSingle(item: UploadItem): Promise<void> {
item.status = 'done'
item.progress = 100
try {
item.stagingFile = JSON.parse(xhr.responseText) as StagingFile
item.bookBucketFile = JSON.parse(xhr.responseText) as BookBucketFile
} catch {
// response parsing optional
}
@@ -75,7 +75,7 @@ function uploadSingle(item: UploadItem): Promise<void> {
const files = ref<UploadItem[]>([])
const isUploading = ref(false)
export function useStagingUpload() {
export function useBookBucketUpload() {
function addFiles(fileList: FileList | File[]) {
for (const file of fileList) {
const error = validateFile(file)
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { StagingAutoFinalizeMetadataMode } from '@projectx/types'
import type { BookBucketAutoFinalizeMetadataMode } from '@projectx/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { api } from '@/lib/api'
@@ -13,7 +13,7 @@ const autoFinalizeEnabled = ref(false)
const autoFinalizeThreshold = ref(85)
const autoFinalizeLibraryId = ref<number | null>(null)
const autoFinalizeFolderId = ref<number | null>(null)
const autoFinalizeMetadataMode = ref<StagingAutoFinalizeMetadataMode>('safe_merge')
const autoFinalizeMetadataMode = ref<BookBucketAutoFinalizeMetadataMode>('safe_merge')
const loading = ref(true)
const saving = ref(false)
@@ -29,12 +29,12 @@ onMounted(async () => {
if (res.ok) {
const settings: { key: string; value: string }[] = await res.json()
const get = (key: string) => settings.find((s) => s.key === key)?.value
autoFetch.value = get('staging_auto_fetch_metadata') !== 'false'
autoFinalizeEnabled.value = get('staging_auto_finalize_enabled') === 'true'
autoFinalizeThreshold.value = parseInt(get('staging_auto_finalize_threshold') ?? '85', 10)
const libId = parseInt(get('staging_auto_finalize_library_id') ?? '', 10)
const folderId = parseInt(get('staging_auto_finalize_folder_id') ?? '', 10)
const metadataMode = get('staging_auto_finalize_metadata_mode')
autoFetch.value = get('book_bucket_auto_fetch_metadata') !== 'false'
autoFinalizeEnabled.value = get('book_bucket_auto_finalize_enabled') === 'true'
autoFinalizeThreshold.value = parseInt(get('book_bucket_auto_finalize_threshold') ?? '85', 10)
const libId = parseInt(get('book_bucket_auto_finalize_library_id') ?? '', 10)
const folderId = parseInt(get('book_bucket_auto_finalize_folder_id') ?? '', 10)
const metadataMode = get('book_bucket_auto_finalize_metadata_mode')
autoFinalizeLibraryId.value = isNaN(libId) ? null : libId
autoFinalizeFolderId.value = isNaN(folderId) ? null : folderId
autoFinalizeMetadataMode.value = metadataMode === 'fetched_only' || metadataMode === 'embedded_only' ? metadataMode : 'safe_merge'
@@ -60,7 +60,7 @@ async function toggle() {
const newVal = !autoFetch.value
saving.value = true
try {
const res = await api('/api/v1/app-settings/staging_auto_fetch_metadata', {
const res = await api('/api/v1/app-settings/book_bucket_auto_fetch_metadata', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
@@ -81,7 +81,7 @@ async function toggleAutoFinalize() {
const newVal = !autoFinalizeEnabled.value
saving.value = true
try {
const res = await api('/api/v1/app-settings/staging_auto_finalize_enabled', {
const res = await api('/api/v1/app-settings/book_bucket_auto_finalize_enabled', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
@@ -103,34 +103,34 @@ async function onLibraryChange(event: Event) {
const lib = libraries.value.find((l) => l.id === id)
autoFinalizeFolderId.value = lib?.folders?.[0]?.id ?? null
await Promise.all([
saveSetting('staging_auto_finalize_library_id', String(id)),
saveSetting('staging_auto_finalize_folder_id', String(autoFinalizeFolderId.value ?? '')),
saveSetting('book_bucket_auto_finalize_library_id', String(id)),
saveSetting('book_bucket_auto_finalize_folder_id', String(autoFinalizeFolderId.value ?? '')),
])
toast.success('Destination library updated')
}
async function onFolderChange(event: Event) {
autoFinalizeFolderId.value = Number((event.target as HTMLSelectElement).value)
await saveSetting('staging_auto_finalize_folder_id', String(autoFinalizeFolderId.value))
await saveSetting('book_bucket_auto_finalize_folder_id', String(autoFinalizeFolderId.value))
toast.success('Destination folder updated')
}
async function onThresholdChange() {
if (!isThresholdApplicable.value) return
await saveSetting('staging_auto_finalize_threshold', String(autoFinalizeThreshold.value))
await saveSetting('book_bucket_auto_finalize_threshold', String(autoFinalizeThreshold.value))
toast.success('Confidence threshold updated')
}
async function onMetadataModeChange(event: Event) {
const value = (event.target as HTMLSelectElement).value as StagingAutoFinalizeMetadataMode
const value = (event.target as HTMLSelectElement).value as BookBucketAutoFinalizeMetadataMode
autoFinalizeMetadataMode.value = value
await saveSetting('staging_auto_finalize_metadata_mode', value)
await saveSetting('book_bucket_auto_finalize_metadata_mode', value)
toast.success('Metadata mode updated')
}
</script>
<template>
<SettingsPageHeader title="Staging" subtitle="Configure how files are processed when they enter the staging area." />
<SettingsPageHeader title="Book Bucket" subtitle="Configure how files are processed when they enter Book Bucket." />
<div v-if="loading" class="flex items-center justify-center py-8">
<Loader2 class="size-5 animate-spin text-muted-foreground" />
@@ -144,7 +144,7 @@ async function onMetadataModeChange(event: Event) {
<div>
<p class="settings-label">Auto-fetch metadata from providers</p>
<p class="settings-hint">
Automatically fetch metadata from configured providers (Google Books, iTunes, Open Library, etc.) after a file is added to staging.
Automatically fetch metadata from configured providers (Google Books, iTunes, Open Library, etc.) after a file is added to Book Bucket.
</p>
</div>
<ToggleSwitch :model-value="autoFetch" :disabled="saving" class="ml-4" @update:model-value="() => toggle()" />
@@ -28,8 +28,8 @@ const sections = computed<Section[]>(() => {
if (su || perms.includes('manage_metadata_config')) {
result.push({ label: 'Metadata', routeName: 'settings-admin-metadata' })
}
if (su || perms.includes('staging_access')) {
result.push({ label: 'Staging', routeName: 'settings-admin-staging' })
if (su || perms.includes('book_bucket_access')) {
result.push({ label: 'Book Bucket', routeName: 'settings-admin-book-bucket' })
}
if (su || perms.includes('manage_app_settings')) {
result.push({ label: 'File Naming', routeName: 'settings-admin-file-naming' })
+8 -8
View File
@@ -141,10 +141,10 @@ export const routes: RouteRecordRaw[] = [
meta: { maxWidth: 'max-w-6xl', title: 'File Naming' },
},
{
path: 'admin/staging',
name: 'settings-admin-staging',
component: () => import('@/features/settings/StagingSettings.vue'),
meta: { title: 'Staging Settings' },
path: 'admin/book-bucket',
name: 'settings-admin-book-bucket',
component: () => import('@/features/settings/BookBucketSettings.vue'),
meta: { title: 'Book Bucket Settings' },
},
{
path: 'admin/maintenance',
@@ -168,10 +168,10 @@ export const routes: RouteRecordRaw[] = [
],
},
{
path: '/staging',
name: 'staging',
component: () => import('@/views/StagingView.vue'),
meta: { title: 'Staging' },
path: '/book-bucket',
name: 'book-bucket',
component: () => import('@/views/BookBucketView.vue'),
meta: { title: 'Book Bucket' },
},
{
path: '/statistics',
@@ -1,19 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { PackageOpen, CheckCircle2, AlertCircle } from 'lucide-vue-next'
import type { StagingFile } from '@projectx/types'
import type { BookBucketFile } from '@projectx/types'
import { api } from '@/lib/api'
import { formatBytes } from '@/lib/formatting'
import { useStagingFiles } from '@/features/staging/composables/useStagingFiles'
import { useStagingSummary } from '@/features/staging/composables/useStagingSummary'
import { useStagingStatistics } from '@/features/staging/composables/useStagingStatistics'
import { useStagingUpload, SUPPORTED_FORMATS } from '@/features/staging/composables/useStagingUpload'
import StagingToolbar from '@/features/staging/components/StagingToolbar.vue'
import StagingFileList from '@/features/staging/components/StagingFileList.vue'
import StagingFileSheet from '@/features/staging/components/StagingFileSheet.vue'
import StagingFinalizeDialog from '@/features/staging/components/StagingFinalizeDialog.vue'
import StagingBulkEditDialog from '@/features/staging/components/StagingBulkEditDialog.vue'
import StagingSetDestinationDialog from '@/features/staging/components/StagingSetDestinationDialog.vue'
import { useBookBucketFiles } from '@/features/book-bucket/composables/useBookBucketFiles'
import { useBookBucketSummary } from '@/features/book-bucket/composables/useBookBucketSummary'
import { useBookBucketStatistics } from '@/features/book-bucket/composables/useBookBucketStatistics'
import { useBookBucketUpload, SUPPORTED_FORMATS } from '@/features/book-bucket/composables/useBookBucketUpload'
import BookBucketToolbar from '@/features/book-bucket/components/BookBucketToolbar.vue'
import BookBucketFileList from '@/features/book-bucket/components/BookBucketFileList.vue'
import BookBucketFileSheet from '@/features/book-bucket/components/BookBucketFileSheet.vue'
import BookBucketFinalizeDialog from '@/features/book-bucket/components/BookBucketFinalizeDialog.vue'
import BookBucketBulkEditDialog from '@/features/book-bucket/components/BookBucketBulkEditDialog.vue'
import BookBucketSetDestinationDialog from '@/features/book-bucket/components/BookBucketSetDestinationDialog.vue'
type ApplyFetchedResult = {
total: number
@@ -25,6 +25,7 @@ type ApplyFetchedResult = {
const {
items,
loading,
initialized,
filters,
pageCount,
fetchFiles,
@@ -39,13 +40,13 @@ const {
selectionCount,
hasSelection,
getSelectionPayload,
} = useStagingFiles()
} = useBookBucketFiles()
const { summary, fetchSummary, subscribe, onStagingChange, socketConnected } = useStagingSummary()
const { statistics, fetchStatistics } = useStagingStatistics()
const { addFiles, isUploading } = useStagingUpload()
const { summary, fetchSummary, subscribe, onBookBucketChange, socketConnected } = useBookBucketSummary()
const { statistics, fetchStatistics } = useBookBucketStatistics()
const { addFiles, isUploading } = useBookBucketUpload()
const selectedFile = ref<StagingFile | null>(null)
const selectedFile = ref<BookBucketFile | null>(null)
const showFinalizeDialog = ref(false)
const showBulkEditDialog = ref(false)
const showSetDestinationDialog = ref(false)
@@ -64,7 +65,7 @@ let namePreviewReqSeq = 0
let prevTotal = -1
const unsubscribeChange = onStagingChange(() => {
const unsubscribeChange = onBookBucketChange(() => {
const currentTotal = summary.value.total
fetchFiles()
if (prevTotal >= 0 && currentTotal > prevTotal) {
@@ -77,7 +78,7 @@ const unsubscribeChange = onStagingChange(() => {
prevTotal = currentTotal
})
function openSheet(file: StagingFile) {
function openSheet(file: BookBucketFile) {
selectedFile.value = file
}
@@ -96,7 +97,7 @@ function refresh() {
void fetchStatistics()
}
function onFileUpdated(updated: StagingFile) {
function onFileUpdated(updated: BookBucketFile) {
const idx = items.value.findIndex((f) => f.id === updated.id)
if (idx !== -1) items.value[idx] = updated
if (selectedFile.value?.id === updated.id) selectedFile.value = updated
@@ -121,12 +122,12 @@ const errorCount = computed(() => {
const emptyMessage = computed(() => {
if (filters.search) return `No files match "${filters.search}"`
if (filters.status) return `No ${filters.status} files`
return 'Upload files or drop them in the staging folder'
return 'Upload files or drop them in the Book Bucket folder'
})
async function handleBulkDiscard() {
const payload = getSelectionPayload()
await api('/api/v1/staging/files/discard', {
await api('/api/v1/book-bucket/files/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -137,7 +138,7 @@ async function handleBulkDiscard() {
async function handleApplyFetched() {
const payload = getSelectionPayload()
const res = await api('/api/v1/staging/files/apply-fetched', {
const res = await api('/api/v1/book-bucket/files/apply-fetched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -160,7 +161,7 @@ async function handleApplyFetched() {
async function handleRetryFetch() {
const payload = getSelectionPayload()
const res = await api('/api/v1/staging/files/retry-fetch', {
const res = await api('/api/v1/book-bucket/files/retry-fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -177,7 +178,7 @@ async function handleRetryFetch() {
async function handleInlineApplyFetched(fileId: number) {
const file = items.value.find((f) => f.id === fileId)
if (!file?.fetchedMetadata) return
const res = await api('/api/v1/staging/files/apply-fetched', {
const res = await api('/api/v1/book-bucket/files/apply-fetched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileIds: [fileId] }),
@@ -264,7 +265,7 @@ async function fetchVisibleNamePreview() {
const reqId = ++namePreviewReqSeq
try {
const res = await api('/api/v1/staging/files/preview-names', {
const res = await api('/api/v1/book-bucket/files/preview-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileIds: ids }),
@@ -354,7 +355,7 @@ onUnmounted(() => {
<div class="flex items-center justify-center size-9 rounded-lg bg-primary/10">
<PackageOpen class="size-4.5 text-primary" />
</div>
<h1 class="text-xl font-semibold text-foreground tracking-tight">Staging</h1>
<h1 class="text-xl font-semibold text-foreground tracking-tight">Book Bucket</h1>
<span
v-if="summary.total > 0"
class="ml-1 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/15 text-primary text-xs font-semibold tabular-nums"
@@ -408,7 +409,7 @@ onUnmounted(() => {
</Transition>
</div>
<StagingToolbar
<BookBucketToolbar
:active-status="filters.status"
:selection-count="selectionCount"
:has-selection="hasSelection"
@@ -429,7 +430,7 @@ onUnmounted(() => {
<!-- Statistics bar -->
<div v-if="statistics && statistics.byFormat.length > 0" class="flex items-center gap-3 flex-wrap text-xs text-muted-foreground">
<span class="font-medium text-foreground">{{ formatBytes(statistics.totalSizeBytes) }} staged</span>
<span class="font-medium text-foreground">{{ formatBytes(statistics.totalSizeBytes) }} in Book Bucket</span>
<span
v-for="f in statistics.byFormat"
:key="f.format"
@@ -449,15 +450,16 @@ onUnmounted(() => {
<PackageOpen class="size-8 text-primary" />
</div>
<div class="text-center">
<p class="text-sm font-medium text-foreground">Drop files to stage</p>
<p class="text-sm font-medium text-foreground">Drop files into Book Bucket</p>
<p class="text-xs text-muted-foreground mt-1">Supported: {{ SUPPORTED_FORMATS.join(', ') }}</p>
</div>
</div>
</div>
<StagingFileList
<BookBucketFileList
:items="items"
:loading="loading"
:initialized="initialized"
:is-selected="isSelected"
:select-all="selectAll"
:name-preview-by-file-id="namePreviewByFileId"
@@ -483,10 +485,10 @@ onUnmounted(() => {
</main>
<Teleport to="body">
<StagingFileSheet v-if="selectedFile" :file="selectedFile" @close="closeSheet" @discarded="onDiscarded" @updated="onFileUpdated" />
<BookBucketFileSheet v-if="selectedFile" :file="selectedFile" @close="closeSheet" @discarded="onDiscarded" @updated="onFileUpdated" />
</Teleport>
<StagingFinalizeDialog
<BookBucketFinalizeDialog
v-if="showFinalizeDialog"
:selection-payload="getSelectionPayload()"
:selection-count="selectionCount"
@@ -494,7 +496,7 @@ onUnmounted(() => {
@finalized="onFinalized"
/>
<StagingBulkEditDialog
<BookBucketBulkEditDialog
v-if="showBulkEditDialog"
:selection-payload="getSelectionPayload()"
:selection-count="selectionCount"
@@ -502,7 +504,7 @@ onUnmounted(() => {
@edited="onBulkEdited"
/>
<StagingSetDestinationDialog
<BookBucketSetDestinationDialog
v-if="showSetDestinationDialog"
:selection-payload="getSelectionPayload()"
:selection-count="selectionCount"