mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
refactor(scanner): rename staging module to book bucket
This commit is contained in:
@@ -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 -->
|
||||
|
||||
@@ -179,7 +179,7 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<!-- Dashboard / Staging / Authors -->
|
||||
<!-- Dashboard / Book Bucket / Authors -->
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
|
||||
@@ -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',
|
||||
|
||||
+5
-5
@@ -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({
|
||||
+21
-20
@@ -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>
|
||||
+17
-17
@@ -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"
|
||||
+4
-4
@@ -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),
|
||||
+1
-1
@@ -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({
|
||||
+4
-4
@@ -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',
|
||||
+7
-7
@@ -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')
|
||||
})
|
||||
})
|
||||
+13
-13
@@ -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 }
|
||||
+24
-14
@@ -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,
|
||||
+5
-5
@@ -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),
|
||||
+4
-4
@@ -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
|
||||
+8
-8
@@ -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 }
|
||||
}
|
||||
+5
-5
@@ -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)
|
||||
+18
-18
@@ -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' })
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user