feat(scanner): improve staging metadata and destination finalize flow

This commit is contained in:
neon
2026-03-11 03:33:31 -06:00
parent 9d25e219ab
commit 2102d0f9d8
25 changed files with 1250 additions and 195 deletions
+26 -1
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ArrowLeft, Search, Palette, Upload, X, KeyRound, Settings, LogOut } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -25,11 +26,13 @@ import { useAuth } from '@/features/auth/composables/useAuth'
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'
const router = useRouter()
const { user, logout } = useAuth()
const { open: openChangePassword } = useChangePasswordDialog()
const { hasPermission } = usePermissions()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const uploadOpen = ref(false)
@@ -113,7 +116,29 @@ function handleGlobalKeydown(e: KeyboardEvent) {
}
onMounted(() => window.addEventListener('keydown', handleGlobalKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleGlobalKeydown))
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount === 0 && event.failedCount === 0) return
const uploadedLabel = `${event.uploadedCount} book${event.uploadedCount === 1 ? '' : 's'}`
const failedLabel = `${event.failedCount} file${event.failedCount === 1 ? '' : 's'}`
if (event.failedCount === 0) {
toast.success(`Uploaded ${uploadedLabel}`)
return
}
if (event.uploadedCount === 0) {
toast.error(`Upload failed for ${failedLabel}`)
return
}
toast.warning(`Uploaded ${uploadedLabel}, ${failedLabel} failed`)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown)
stopUploadCompletedListener()
})
function highlightSegments(text: string | null, query: string) {
if (!text || !query.trim()) return [{ text: text ?? '', match: false }]
+11 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch, type Component } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as Icons from 'lucide-vue-next'
import { Aperture, FolderOpen, LayoutDashboard, PackageOpen, Users } from 'lucide-vue-next'
@@ -22,6 +22,7 @@ import { useCollections } from '@/features/collection/composables/useCollections
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
import { useStagingSummary } from '@/features/staging/composables/useStagingSummary'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import type { Library } from '@projectx/types'
import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue'
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
@@ -49,6 +50,7 @@ const { collections, fetchCollections } = useCollections()
const { hasPermission } = usePermissions()
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
const { summary: stagingSummary, fetchSummary: fetchStagingSummary, subscribe: subscribeStagingSummary } = useStagingSummary()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const createLensOpen = ref(false)
const createCollectionOpen = ref(false)
@@ -114,6 +116,14 @@ onMounted(async () => {
subscribeStagingSummary()
}
})
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount > 0) {
refreshLibraries()
}
})
onUnmounted(() => stopUploadCompletedListener())
</script>
<template>
@@ -4,6 +4,7 @@ import { CheckCircle2, FileUp, Loader2, Upload, X, XCircle } from 'lucide-vue-ne
import { api } from '@/lib/api'
import type { Library } from '@projectx/types'
import { SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT, useBookUpload } from '../composables/useBookUpload'
import { emitLibraryUploadCompleted } from '../composables/useLibraryUploadEvents'
import { useLibraries } from '../composables/useLibraries'
const props = defineProps<{
@@ -92,8 +93,29 @@ function onFileInputChange(e: Event) {
}
async function handleUpload() {
if (selectedLibraryId.value === undefined) return
await startUpload(selectedLibraryId.value, selectedFolderId.value)
const libraryId = selectedLibraryId.value
if (libraryId === undefined) return
const folderId = selectedFolderId.value
const pendingAtStart = files.value.filter((f) => f.status === 'pending').length
if (pendingAtStart === 0) return
const uploadedBefore = new Set(files.value.filter((f) => f.status === 'done' && f.bookId !== undefined).map((f) => f.bookId!))
const errorCountBefore = files.value.filter((f) => f.status === 'error').length
await startUpload(libraryId, folderId)
const uploadedAfter = files.value.filter((f) => f.status === 'done' && f.bookId !== undefined).map((f) => f.bookId!)
const uploadedBookIds = uploadedAfter.filter((id) => !uploadedBefore.has(id))
const errorCountAfter = files.value.filter((f) => f.status === 'error').length
emitLibraryUploadCompleted({
libraryId,
uploadedBookIds,
uploadedCount: uploadedBookIds.length,
failedCount: Math.max(0, errorCountAfter - errorCountBefore),
attemptedCount: pendingAtStart,
})
}
function formatBytes(bytes: number): string {
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { emitLibraryUploadCompleted, useLibraryUploadEvents } from '../useLibraryUploadEvents'
describe('useLibraryUploadEvents', () => {
it('notifies subscribers with the emitted event payload', () => {
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const cb = vi.fn()
const off = onLibraryUploadCompleted(cb)
const event = {
libraryId: 7,
uploadedBookIds: [101, 102],
uploadedCount: 2,
failedCount: 1,
attemptedCount: 3,
}
emitLibraryUploadCompleted(event)
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenCalledWith(event)
off()
})
it('stops notifying once unsubscribed', () => {
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const cb = vi.fn()
const off = onLibraryUploadCompleted(cb)
off()
emitLibraryUploadCompleted({
libraryId: 5,
uploadedBookIds: [1],
uploadedCount: 1,
failedCount: 0,
attemptedCount: 1,
})
expect(cb).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,24 @@
export interface LibraryUploadCompletedEvent {
libraryId: number
uploadedBookIds: number[]
uploadedCount: number
failedCount: number
attemptedCount: number
}
type LibraryUploadCompletedCallback = (event: LibraryUploadCompletedEvent) => void
const completedCallbacks = new Set<LibraryUploadCompletedCallback>()
export function emitLibraryUploadCompleted(event: LibraryUploadCompletedEvent): void {
for (const cb of completedCallbacks) cb(event)
}
export function useLibraryUploadEvents() {
function onLibraryUploadCompleted(cb: LibraryUploadCompletedCallback): () => void {
completedCallbacks.add(cb)
return () => completedCallbacks.delete(cb)
}
return { onLibraryUploadCompleted }
}
@@ -5,7 +5,7 @@ import type { StagingBulkEditResult, StagingMetadata } from '@projectx/types'
import { api } from '@/lib/api'
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[] }
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
selectionCount: number
}>()
@@ -50,12 +50,15 @@ async function submit() {
if (!enabledFields.has(f.key)) continue
const raw = values[f.key] ?? ''
if (f.type === 'array') {
(fields as Record<string, unknown>)[f.key] = raw.split(',').map((s) => s.trim()).filter(Boolean)
;(fields as Record<string, unknown>)[f.key] = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
} else if (f.type === 'number') {
const n = parseFloat(raw)
if (!isNaN(n)) (fields as Record<string, unknown>)[f.key] = n
} else {
(fields as Record<string, unknown>)[f.key] = raw || undefined
;(fields as Record<string, unknown>)[f.key] = raw || undefined
}
}
@@ -98,7 +101,10 @@ function handleClose() {
<h2 class="text-base font-semibold text-foreground">
{{ result ? 'Bulk Edit Results' : `Bulk Edit ${selectionCount} file${selectionCount === 1 ? '' : 's'}` }}
</h2>
<button class="size-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-all" @click="handleClose">
<button
class="size-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
@click="handleClose"
>
<X class="size-4" />
</button>
</div>
@@ -108,10 +114,20 @@ function handleClose() {
<div class="space-y-2">
<div v-for="f in FIELDS" :key="f.key" class="flex items-center gap-2">
<input type="checkbox" :checked="enabledFields.has(f.key)" class="size-3.5 rounded border-input accent-primary shrink-0" @change="toggleField(f.key)" />
<input
type="checkbox"
:checked="enabledFields.has(f.key)"
class="size-3.5 rounded border-input accent-primary shrink-0"
@change="toggleField(f.key)"
/>
<label class="flex-1">
<span class="text-xs font-medium text-muted-foreground block mb-0.5">{{ f.label }}</span>
<input v-model="values[f.key]" :disabled="!enabledFields.has(f.key)" :placeholder="f.type === 'array' ? 'comma-separated' : ''" class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring disabled:opacity-40" />
<input
v-model="values[f.key]"
:disabled="!enabledFields.has(f.key)"
:placeholder="f.type === 'array' ? 'comma-separated' : ''"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring disabled:opacity-40"
/>
</label>
</div>
</div>
@@ -127,7 +143,11 @@ function handleClose() {
<button class="h-8 px-4 rounded-lg text-sm text-muted-foreground hover:text-foreground transition-all" @click="handleClose">
Cancel
</button>
<button class="flex items-center gap-1.5 h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95 disabled:opacity-50" :disabled="enabledFields.size === 0 || loading" @click="submit">
<button
class="flex items-center gap-1.5 h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95 disabled:opacity-50"
:disabled="enabledFields.size === 0 || loading"
@click="submit"
>
<Loader2 v-if="loading" class="size-3.5 animate-spin" />
Apply
</button>
@@ -140,7 +160,10 @@ function handleClose() {
<p v-if="result.failed > 0" class="text-xs text-muted-foreground mt-0.5">{{ result.failed }} failed</p>
</div>
<div class="flex justify-end">
<button class="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95" @click="handleClose">
<button
class="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95"
@click="handleClose"
>
Done
</button>
</div>
@@ -149,4 +172,3 @@ function handleClose() {
</div>
</Teleport>
</template>
@@ -1,19 +1,24 @@
<script setup lang="ts">
import { BookOpen, FileText, Wand2, Pencil } from 'lucide-vue-next'
import { onMounted, ref } from 'vue'
import { BookOpen, FileText, Wand2, Pencil, X } from 'lucide-vue-next'
import type { StagingFile, StagingMetadata } 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useLibraries } from '@/features/library/composables/useLibraries'
withDefaults(
const props = withDefaults(
defineProps<{
items: StagingFile[]
loading: boolean
isSelected: (id: number) => boolean
selectAll: boolean
namePreviewByFileId?: Record<number, string>
emptyMessage?: string
}>(),
{
namePreviewByFileId: () => ({}),
emptyMessage: 'Upload files or drop them in the staging folder',
},
)
@@ -29,18 +34,62 @@ function backendCoverUrl(file: StagingFile): string {
return `/api/v1/staging/files/${file.id}/cover?v=${new Date(file.updatedAt).getTime()}`
}
function coverUrl(file: StagingFile): string {
function selectedCoverUrl(file: StagingFile): string | null {
const url = file.selectedMetadata?.coverUrl?.trim()
return url ? url : null
}
function newCoverUrl(file: StagingFile): 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 {
return backendCoverUrl(file)
}
function onCoverError(event: Event, file: StagingFile) {
function onCurrentCoverError(event: Event) {
const img = event.target as HTMLImageElement
const externalUrl = file.selectedMetadata?.coverUrl ?? file.fetchedMetadata?.coverUrl
if (img.src.includes('/api/v1/staging/files/') && externalUrl) {
img.src = externalUrl
} else {
img.style.display = 'none'
img.style.display = 'none'
}
function onFetchedCoverError(event: Event) {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
function currentCoverLightboxFallback(): null {
return null
}
const coverLightbox = ref<{ src: string; fallback: string | null; title: string } | null>(null)
function openCoverLightbox(src: string, title: string, fallback: string | null = null) {
coverLightbox.value = { src, fallback, title }
}
function closeCoverLightbox() {
coverLightbox.value = null
}
function onLightboxOpenChange(open: boolean) {
if (!open) closeCoverLightbox()
}
function onLightboxCoverError(event: Event) {
const img = event.target as HTMLImageElement
const fallback = coverLightbox.value?.fallback
if (fallback) {
const currentSrc = img.currentSrc || img.src
const resolvedFallback = new URL(fallback, window.location.origin).href
if (currentSrc !== resolvedFallback) {
img.src = fallback
return
}
}
img.style.display = 'none'
}
function displayTitle(file: StagingFile): string {
@@ -74,6 +123,35 @@ function confidenceBadgeClass(file: StagingFile): string {
if (c >= 50) return 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
return 'bg-red-500/15 text-red-600 dark:text-red-400'
}
const { libraries, fetchLibraries } = useLibraries()
onMounted(() => {
void fetchLibraries()
})
function joinTargetPath(folderPath: string, fileName: string): string {
const base = folderPath.replace(/[\\/]+$/, '')
const separator = base.includes('\\') ? '\\' : '/'
return `${base}${separator}${fileName}`
}
function targetSummary(file: StagingFile): 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')
const previewName = props.namePreviewByFileId?.[file.id] ?? file.fileName
if (lib && folder?.path) {
return `Target: ${libLabel} · ${joinTargetPath(folder.path, previewName)}`
}
if (file.targetLibraryId == null || file.targetFolderId == null) {
return 'Target: Unassigned'
}
return `Target: ${libLabel} · Unknown destination path`
}
</script>
<template>
@@ -101,6 +179,8 @@ function confidenceBadgeClass(file: StagingFile): string {
<div v-else class="divide-y divide-border">
<div class="flex items-center gap-3 px-4 py-2 bg-muted/30 border-b border-border">
<input type="checkbox" :checked="selectAll" class="size-3.5 rounded border-input accent-primary" @change="$emit('selectAll')" />
<span class="text-xs font-medium text-muted-foreground w-12 text-center shrink-0">Current</span>
<span class="text-xs font-medium text-muted-foreground w-12 text-center shrink-0">New</span>
<span class="text-xs font-medium text-muted-foreground flex-1">File</span>
<span class="text-xs font-medium text-muted-foreground w-16 text-right hidden sm:block">Size</span>
<span class="text-xs font-medium text-muted-foreground w-12 text-center hidden sm:block">Format</span>
@@ -112,7 +192,7 @@ function confidenceBadgeClass(file: StagingFile): string {
<button
v-for="file in items"
:key="file.id"
class="flex items-center gap-3 px-4 py-2.5 w-full text-left hover:bg-muted/50 transition-colors cursor-pointer"
class="flex items-center gap-3 px-4 py-3.5 w-full text-left hover:bg-muted/50 transition-colors cursor-pointer"
@click="$emit('open', file)"
>
<input
@@ -123,9 +203,38 @@ function confidenceBadgeClass(file: StagingFile): string {
@change.stop="$emit('select', file.id)"
/>
<div class="relative size-10 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
<img :src="coverUrl(file)" alt="" class="size-full object-cover" @load="($event.target as HTMLImageElement).style.display = ''" @error="onCoverError($event, file)" />
<BookOpen class="size-4 text-muted-foreground absolute" />
<div class="w-12 shrink-0 flex justify-center">
<div
class="relative size-12 rounded-lg bg-muted flex items-center justify-center overflow-hidden cursor-zoom-in ring-1 ring-border"
@click.stop="openCoverLightbox(currentCoverUrl(file), `${displayTitle(file)} current cover`, currentCoverLightboxFallback())"
>
<img
:src="currentCoverUrl(file)"
alt=""
class="size-full object-cover"
@load="($event.target as HTMLImageElement).style.display = ''"
@error="onCurrentCoverError"
/>
<BookOpen class="size-4 text-muted-foreground absolute" />
</div>
</div>
<div class="w-12 shrink-0 flex justify-center">
<div
v-if="newCoverUrl(file)"
class="relative size-12 rounded-lg bg-amber-500/5 border border-amber-500/30 flex items-center justify-center overflow-hidden cursor-zoom-in"
@click.stop="openCoverLightbox(newCoverUrl(file)!, `${displayTitle(file)} new cover`)"
>
<img
:src="newCoverUrl(file)!"
alt=""
class="size-full object-cover"
@load="($event.target as HTMLImageElement).style.display = ''"
@error="onFetchedCoverError($event)"
/>
<Wand2 class="size-3 text-amber-500 absolute" />
</div>
<div v-else class="size-12 rounded-lg border border-dashed border-border/70 bg-muted/30" />
</div>
<div class="flex-1 min-w-0">
@@ -148,6 +257,7 @@ function confidenceBadgeClass(file: StagingFile): string {
Edited
</span>
</div>
<p class="mt-0.5 text-[11px] text-muted-foreground truncate">{{ targetSummary(file) }}</p>
</div>
<span class="text-xs text-muted-foreground w-16 text-right shrink-0 hidden sm:block tabular-nums">
@@ -188,5 +298,31 @@ function confidenceBadgeClass(file: StagingFile): string {
</div>
</button>
</div>
<DialogRoot :open="!!coverLightbox" @update:open="onLightboxOpenChange">
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
class="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 max-w-[90vw] max-h-[90vh] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
>
<DialogTitle class="sr-only">{{ coverLightbox?.title ?? 'Cover preview' }}</DialogTitle>
<DialogDescription class="sr-only">Enlarged cover image preview dialog.</DialogDescription>
<img
v-if="coverLightbox"
:src="coverLightbox.src"
:alt="coverLightbox.title"
class="max-w-[90vw] max-h-[90vh] rounded-md shadow-2xl object-contain"
@error="onLightboxCoverError"
/>
<DialogClose
class="absolute -top-3 -right-3 p-1 rounded-full bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
>
<X class="size-4" />
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</div>
</template>
@@ -50,6 +50,7 @@ const form = reactive({
seriesIndex: '',
genres: '',
})
const selectedCoverUrl = ref('')
watch(
() => props.file.id,
@@ -67,6 +68,7 @@ watch(
form.seriesName = m.seriesName ?? ''
form.seriesIndex = m.seriesIndex != null ? String(m.seriesIndex) : ''
form.genres = m.genres?.join(', ') ?? ''
selectedCoverUrl.value = m.coverUrl ?? ''
metaView.value = 'editor'
targetLibraryId.value = props.file.targetLibraryId ?? libraries.value[0]?.id ?? null
@@ -96,49 +98,57 @@ onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
function buildMetadataPatchFromForm(): Partial<StagingMetadata> {
return {
title: form.title || undefined,
subtitle: form.subtitle || undefined,
authors: form.authors
? form.authors
.split(',')
.map((a) => a.trim())
.filter(Boolean)
: undefined,
description: form.description || undefined,
publisher: form.publisher || undefined,
publishedYear: form.publishedYear ? ((n) => (isNaN(n) ? undefined : n))(parseInt(form.publishedYear, 10)) : undefined,
language: form.language || undefined,
isbn13: form.isbn13 || undefined,
isbn10: form.isbn10 || undefined,
seriesName: form.seriesName || undefined,
seriesIndex: form.seriesIndex ? ((n) => (isNaN(n) ? undefined : n))(parseFloat(form.seriesIndex)) : undefined,
genres: form.genres
? form.genres
.split(',')
.map((g) => g.trim())
.filter(Boolean)
: undefined,
coverUrl: selectedCoverUrl.value || undefined,
}
}
function onFieldChange() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
const patch: Partial<StagingMetadata> = {
title: form.title || undefined,
subtitle: form.subtitle || undefined,
authors: form.authors
? form.authors
.split(',')
.map((a) => a.trim())
.filter(Boolean)
: undefined,
description: form.description || undefined,
publisher: form.publisher || undefined,
publishedYear: form.publishedYear ? ((n) => (isNaN(n) ? undefined : n))(parseInt(form.publishedYear, 10)) : undefined,
language: form.language || undefined,
isbn13: form.isbn13 || undefined,
isbn10: form.isbn10 || undefined,
seriesName: form.seriesName || undefined,
seriesIndex: form.seriesIndex ? ((n) => (isNaN(n) ? undefined : n))(parseFloat(form.seriesIndex)) : undefined,
genres: form.genres
? form.genres
.split(',')
.map((g) => g.trim())
.filter(Boolean)
: undefined,
}
const updated = await saveMetadata(props.file.id, patch)
const updated = await saveMetadata(props.file.id, buildMetadataPatchFromForm())
if (updated) emit('updated', updated)
}, 1000)
}
function onLibraryChange(event: Event) {
const id = Number((event.target as HTMLSelectElement).value)
async function onLibraryChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
const id = Number.isFinite(raw) && raw > 0 ? raw : null
targetLibraryId.value = id
const lib = libraries.value.find((l) => l.id === id)
const lib = libraries.value.find((l) => l.id === targetLibraryId.value)
targetFolderId.value = lib?.folders?.[0]?.id ?? null
setTarget(props.file.id, targetLibraryId.value, targetFolderId.value)
const updated = await setTarget(props.file.id, targetLibraryId.value, targetFolderId.value)
if (updated) emit('updated', updated)
}
function onFolderChange(event: Event) {
targetFolderId.value = Number((event.target as HTMLSelectElement).value)
setTarget(props.file.id, targetLibraryId.value, targetFolderId.value)
async function onFolderChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
targetFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null
const updated = await setTarget(props.file.id, targetLibraryId.value, targetFolderId.value)
if (updated) emit('updated', updated)
}
function formatDate(iso: string): string {
@@ -221,25 +231,30 @@ function backFromDiff() {
async function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
const p = patch.formPatch
if (p.title != null) form.title = p.title
if (p.subtitle != null) form.subtitle = p.subtitle
if (p.description != null) form.description = p.description
if (p.publisher != null) form.publisher = p.publisher
if (p.publishedYear != null) form.publishedYear = String(p.publishedYear)
if (p.language != null) form.language = p.language
if (p.isbn13 != null) form.isbn13 = p.isbn13
if (p.isbn10 != null) form.isbn10 = p.isbn10
if (p.seriesName != null) form.seriesName = p.seriesName
if (p.seriesIndex != null) form.seriesIndex = String(p.seriesIndex)
if (p.authors) form.authors = p.authors.join(', ')
if (p.genres) form.genres = p.genres.join(', ')
if ('title' in p) form.title = p.title ?? ''
if ('subtitle' in p) form.subtitle = p.subtitle ?? ''
if ('description' in p) form.description = p.description ?? ''
if ('publisher' in p) form.publisher = p.publisher ?? ''
if ('publishedYear' in p) form.publishedYear = p.publishedYear == null ? '' : String(p.publishedYear)
if ('language' in p) form.language = p.language ?? ''
if ('isbn13' in p) form.isbn13 = p.isbn13 ?? ''
if ('isbn10' in p) form.isbn10 = p.isbn10 ?? ''
if ('seriesName' in p) form.seriesName = p.seriesName ?? ''
if ('seriesIndex' in p) form.seriesIndex = p.seriesIndex == null ? '' : String(p.seriesIndex)
if ('authors' in p) form.authors = (p.authors ?? []).join(', ')
if ('genres' in p) form.genres = (p.genres ?? []).join(', ')
if (patch.coverUrl) {
const updated = await saveMetadata(props.file.id, { coverUrl: patch.coverUrl })
if (updated) emit('updated', updated)
if (patch.coverUrl !== undefined) {
selectedCoverUrl.value = patch.coverUrl
}
onFieldChange()
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
const updated = await saveMetadata(props.file.id, buildMetadataPatchFromForm())
if (updated) emit('updated', updated)
metaView.value = 'editor'
selectedCandidate.value = null
}
@@ -250,7 +265,28 @@ const hasFetchedMetadata = computed(() => {
return Object.values(f).some((v) => v !== undefined && v !== null && v !== '')
})
const currentStagingCoverUrl = computed(() => `${coverUrl(props.file.id)}?v=${new Date(props.file.updatedAt).getTime()}`)
const backendStagingCoverUrl = 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))
function onCurrentStagingCoverError(event: Event) {
const img = event.target as HTMLImageElement
const fallback = currentStagingCoverFallbackUrl.value
if (!fallback) {
img.style.display = 'none'
return
}
const resolvedFallback = new URL(fallback, window.location.origin).href
const currentSrc = img.currentSrc || img.src
if (currentSrc !== resolvedFallback) {
img.src = fallback
return
}
img.style.display = 'none'
}
function openFetchedDiff() {
const f = props.file.fetchedMetadata
@@ -306,7 +342,8 @@ onMounted(() => {
:src="currentStagingCoverUrl"
alt=""
class="size-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
@load="($event.target as HTMLImageElement).style.display = ''"
@error="onCurrentStagingCoverError"
/>
<BookOpen class="size-5 text-muted-foreground absolute" />
</div>
@@ -487,7 +524,10 @@ onMounted(() => {
<div class="flex items-center gap-2">
<button
class="flex items-center gap-1.5 h-8 px-3.5 rounded-lg text-primary-foreground text-sm font-medium transition-all active:scale-95"
style="background: linear-gradient(to right, var(--primary), color-mix(in oklch, var(--primary) 65%, oklch(0.7 0.25 280))); box-shadow: 0 2px 10px color-mix(in oklch, var(--primary) 45%, transparent);"
style="
background: linear-gradient(to right, var(--primary), color-mix(in oklch, var(--primary) 65%, oklch(0.7 0.25 280)));
box-shadow: 0 2px 10px color-mix(in oklch, var(--primary) 45%, transparent);
"
@click="openSearch"
>
<Sparkles class="size-3.5" />
@@ -8,7 +8,7 @@ import { useLibraries } from '@/features/library/composables/useLibraries'
import { useStagingFinalize } from '../composables/useStagingFinalize'
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[] }
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
selectionCount: number
}>()
@@ -24,56 +24,102 @@ const { result, loading, error, finalize, reset } = useStagingFinalize()
const defaultLibraryId = ref<number | null>(null)
const defaultFolderId = ref<number | null>(null)
const expandedErrors = ref<Set<number>>(new Set())
const selectionSummary = ref<{ total: number; withDestination: number; withoutDestination: number } | null>(null)
const duplicateCount = computed(() => result.value?.results.filter((r) => r.isDuplicate).length ?? 0)
const namePreview = ref<{ fileId: number; fileName: string; newName: string }[]>([])
const previewLoading = ref(false)
let previewReqSeq = 0
const selectedLibrary = computed(() => libraries.value.find((l) => l.id === defaultLibraryId.value))
const folders = computed(() => selectedLibrary.value?.folders ?? [])
const canStart = computed(() => defaultLibraryId.value !== null && defaultFolderId.value !== null)
const requiresDefaultDestination = computed(() => (selectionSummary.value?.withoutDestination ?? props.selectionCount) > 0)
const canStart = computed(() => {
if (!selectionSummary.value) return false
if (!requiresDefaultDestination.value) return true
return defaultLibraryId.value !== null && defaultFolderId.value !== null
})
onMounted(async () => {
await fetchLibraries()
await Promise.all([fetchLibraries(), fetchSelectionSummary()])
const first = libraries.value[0]
if (first) {
if (requiresDefaultDestination.value && first) {
defaultLibraryId.value = first.id
const firstFolder = first.folders?.[0]
if (firstFolder) defaultFolderId.value = firstFolder.id
}
void fetchNamePreview(defaultLibraryId.value)
void fetchNamePreview(requiresDefaultDestination.value ? defaultLibraryId.value : null)
})
function onLibraryChange(event: Event) {
const id = Number((event.target as HTMLSelectElement).value)
const raw = Number((event.target as HTMLSelectElement).value)
const id = Number.isFinite(raw) && raw > 0 ? raw : null
if (id === null) {
defaultLibraryId.value = null
defaultFolderId.value = null
return
}
defaultLibraryId.value = id
const lib = libraries.value.find((l) => l.id === id)
defaultFolderId.value = lib?.folders?.[0]?.id ?? null
}
function onFolderChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
defaultFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null
}
async function fetchNamePreview(libId: number | null) {
if (!libId) {
if (requiresDefaultDestination.value && !libId) {
namePreview.value = []
previewLoading.value = false
return
}
const reqId = ++previewReqSeq
previewLoading.value = true
try {
const payload = {
...props.selectionPayload,
...(requiresDefaultDestination.value && libId ? { defaultLibraryId: libId } : {}),
}
const res = await api('/api/v1/staging/files/preview-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...props.selectionPayload, defaultLibraryId: libId }),
body: JSON.stringify(payload),
})
namePreview.value = res.ok ? await res.json() : []
if (!res.ok || reqId !== previewReqSeq) return
const rows: { fileId: number; fileName: string; newName: string }[] = await res.json()
if (reqId !== previewReqSeq) return
namePreview.value = rows
} catch {
if (reqId !== previewReqSeq) return
namePreview.value = []
} finally {
if (reqId !== previewReqSeq) return
previewLoading.value = false
}
}
watch(defaultLibraryId, (libId) => {
async function fetchSelectionSummary() {
try {
const res = await api('/api/v1/staging/files/selection-summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(props.selectionPayload),
})
if (res.ok) {
selectionSummary.value = await res.json()
return
}
} catch {
// fallback handled below
}
selectionSummary.value = { total: props.selectionCount, withDestination: 0, withoutDestination: props.selectionCount }
}
watch([defaultLibraryId, requiresDefaultDestination], ([libId]) => {
void fetchNamePreview(libId)
})
@@ -81,8 +127,12 @@ async function start() {
if (!canStart.value) return
await finalize({
...props.selectionPayload,
defaultLibraryId: defaultLibraryId.value!,
defaultFolderId: defaultFolderId.value!,
...(requiresDefaultDestination.value
? {
defaultLibraryId: defaultLibraryId.value!,
defaultFolderId: defaultFolderId.value!,
}
: {}),
})
if (result.value?.succeeded) refreshLibraries()
}
@@ -117,9 +167,19 @@ function goToBook(bookId: number) {
</div>
<div v-if="!result" class="px-5 py-4 space-y-4">
<div class="space-y-3">
<div v-if="selectionSummary" class="rounded-lg border border-border bg-muted/20 px-3 py-2.5">
<p class="text-xs text-muted-foreground">
{{
requiresDefaultDestination
? `${selectionSummary.withoutDestination} of ${selectionSummary.total} selected file${selectionSummary.total === 1 ? '' : 's'} need a destination`
: 'All selected files already have destination set'
}}
</p>
</div>
<div v-if="requiresDefaultDestination" class="space-y-3">
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Destination Library</span>
<span class="text-xs font-medium text-muted-foreground">Default Destination Library</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="defaultLibraryId ?? ''"
@@ -130,11 +190,11 @@ function goToBook(bookId: number) {
</label>
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Destination Folder</span>
<span class="text-xs font-medium text-muted-foreground">Default Destination Folder</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="defaultFolderId ?? ''"
@change="defaultFolderId = Number(($event.target as HTMLSelectElement).value)"
@change="onFolderChange"
>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { X, Loader2, FolderPlus } from 'lucide-vue-next'
import { api } from '@/lib/api'
import { useLibraries } from '@/features/library/composables/useLibraries'
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
selectionCount: number
}>()
const emit = defineEmits<{
close: []
updated: []
}>()
const { libraries, fetchLibraries } = useLibraries()
const loading = ref(false)
const error = ref<string | null>(null)
const result = ref<{ total: number; updated: number; failed: number } | null>(null)
const targetLibraryId = ref<number | null>(null)
const targetFolderId = ref<number | null>(null)
const selectedLibrary = computed(() => libraries.value.find((l) => l.id === targetLibraryId.value))
const folders = computed(() => selectedLibrary.value?.folders ?? [])
const canApply = computed(() => targetLibraryId.value !== null && targetFolderId.value !== null && !loading.value)
onMounted(async () => {
await fetchLibraries()
const first = libraries.value[0]
if (!first) return
targetLibraryId.value = first.id
targetFolderId.value = first.folders?.[0]?.id ?? null
})
function onLibraryChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
const id = Number.isFinite(raw) && raw > 0 ? raw : null
if (id === null) {
targetLibraryId.value = null
targetFolderId.value = null
return
}
targetLibraryId.value = id
const lib = libraries.value.find((l) => l.id === id)
targetFolderId.value = lib?.folders?.[0]?.id ?? null
}
function onFolderChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
targetFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null
}
async function applyDestination() {
if (!canApply.value) return
loading.value = true
error.value = null
try {
const res = await api('/api/v1/staging/files/set-target', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...props.selectionPayload,
targetLibraryId: targetLibraryId.value,
targetFolderId: targetFolderId.value,
}),
})
if (res.ok) {
result.value = await res.json()
} else {
const body = await res.json().catch(() => null)
error.value = (body as { message?: string } | null)?.message ?? `Error ${res.status}`
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to set destination'
} finally {
loading.value = false
}
}
function handleClose() {
if (result.value) emit('updated')
else emit('close')
}
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="handleClose" />
<div class="relative z-10 w-full max-w-md mx-4 bg-card border border-border rounded-xl shadow-2xl overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">
{{ result ? 'Destination Updated' : `Set Destination for ${selectionCount} file${selectionCount === 1 ? '' : 's'}` }}
</h2>
<button
class="size-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
@click="handleClose"
>
<X class="size-4" />
</button>
</div>
<div v-if="!result" class="px-5 py-4 space-y-4">
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Destination Library</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="targetLibraryId ?? ''"
@change="onLibraryChange"
>
<option v-for="lib in libraries" :key="lib.id" :value="lib.id">{{ lib.name }}</option>
</select>
</label>
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Destination Folder</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="targetFolderId ?? ''"
@change="onFolderChange"
>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
</label>
<p v-if="error" class="text-xs text-red-500 bg-red-500/10 rounded-lg p-2">{{ error }}</p>
<div class="flex items-center justify-end gap-2 pt-1">
<button class="h-8 px-4 rounded-lg text-sm text-muted-foreground hover:text-foreground transition-all" @click="handleClose">
Cancel
</button>
<button
class="flex items-center gap-1.5 h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95 disabled:opacity-50"
:disabled="!canApply"
@click="applyDestination"
>
<Loader2 v-if="loading" class="size-3.5 animate-spin" />
<FolderPlus v-else class="size-3.5" />
Apply
</button>
</div>
</div>
<div v-else class="px-5 py-4 space-y-4">
<div class="rounded-lg bg-emerald-500/10 p-3">
<p class="text-sm font-medium">Updated {{ result.updated }} of {{ result.total }} files</p>
<p v-if="result.failed > 0" class="text-xs text-muted-foreground mt-0.5">{{ result.failed }} failed</p>
</div>
<div class="flex justify-end">
<button
class="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95"
@click="handleClose"
>
Done
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw } from 'lucide-vue-next'
import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw, FolderPlus } from 'lucide-vue-next'
import type { StagingFileStatus } from '@projectx/types'
import { api } from '@/lib/api'
import { SUPPORTED_FORMATS_ACCEPT, useStagingUpload } from '../composables/useStagingUpload'
@@ -19,6 +19,7 @@ const emit = defineEmits<{
rescan: []
rescanError: []
retryFetch: []
setDestination: []
bulkDiscard: []
bulkEdit: []
finalize: []
@@ -148,6 +149,13 @@ function clearSearch() {
<FileText class="size-3.5" />
Finalize
</button>
<button
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-emerald-500/12 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/20 transition-all active:scale-95"
@click="$emit('setDestination')"
>
<FolderPlus class="size-3.5" />
Set Destination
</button>
<Tooltip v-if="fetchedCount > 0">
<TooltipTrigger as-child>
<button
@@ -167,7 +175,7 @@ function clearSearch() {
<Tooltip v-if="errorCount > 0">
<TooltipTrigger as-child>
<button
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-muted text-muted-foreground hover:text-foreground transition-all active:scale-95"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-sky-500/12 text-sky-700 dark:text-sky-300 hover:bg-sky-500/20 transition-all active:scale-95"
@click="$emit('retryFetch')"
>
<RefreshCw class="size-3.5" />
@@ -181,7 +189,7 @@ function clearSearch() {
<TooltipContent>Retry metadata fetch for {{ errorCount }} error file{{ errorCount !== 1 ? 's' : '' }}</TooltipContent>
</Tooltip>
<button
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-muted text-muted-foreground hover:text-foreground transition-all active:scale-95"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-violet-500/12 text-violet-700 dark:text-violet-300 hover:bg-violet-500/20 transition-all active:scale-95"
@click="$emit('bulkEdit')"
>
<PenLine class="size-3.5" />
@@ -54,13 +54,18 @@ export function useStagingDetail() {
debounceTimer = setTimeout(() => saveMetadata(id, metadata), 1000)
}
async function setTarget(id: number, libraryId: number | null, folderId: number | null) {
async function setTarget(id: number, libraryId: number | null, folderId: number | null): Promise<StagingFile | null> {
const res = await api(`/api/v1/staging/files/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetLibraryId: libraryId, targetFolderId: folderId }),
})
if (res.ok) file.value = await res.json()
if (res.ok) {
const updated: StagingFile = await res.json()
file.value = updated
return updated
}
return null
}
async function discardFile(id: number) {
@@ -22,10 +22,12 @@ export function useStagingFiles() {
const selectedIds = ref<Set<number>>(new Set())
const selectAll = ref(false)
const excludedIds = ref<Set<number>>(new Set())
let fetchReqSeq = 0
const pageCount = computed(() => Math.ceil(total.value / filters.limit) || 1)
async function fetchFiles() {
const reqId = ++fetchReqSeq
loading.value = true
try {
const params = new URLSearchParams()
@@ -39,21 +41,25 @@ export function useStagingFiles() {
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
}
} finally {
if (reqId !== fetchReqSeq) return
loading.value = false
}
}
function setStatus(status: StagingFileStatus | undefined) {
clearSelection()
filters.status = status
filters.page = 1
fetchFiles()
}
function setSearch(search: string) {
clearSelection()
filters.search = search
filters.page = 1
fetchFiles()
@@ -108,15 +114,20 @@ export function useStagingFiles() {
}
const selectionCount = computed(() => {
if (selectAll.value) return total.value - excludedIds.value.size
if (selectAll.value) return Math.max(0, total.value - excludedIds.value.size)
return selectedIds.value.size
})
const hasSelection = computed(() => selectionCount.value > 0)
function getSelectionPayload(): { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[] } {
function getSelectionPayload(): { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: StagingFileStatus; search?: string } {
if (selectAll.value) {
return { selectAll: true, excludedIds: [...excludedIds.value] }
return {
selectAll: true,
excludedIds: [...excludedIds.value],
...(filters.status ? { status: filters.status } : {}),
...(filters.search ? { search: filters.search } : {}),
}
}
return { fileIds: [...selectedIds.value] }
}
@@ -11,8 +11,10 @@ export function useStagingFinalize() {
fileIds?: number[]
selectAll?: boolean
excludedIds?: number[]
defaultLibraryId: number
defaultFolderId: number
status?: string
search?: string
defaultLibraryId?: number
defaultFolderId?: number
overrides?: StagingFinalizeOverride[]
}) {
loading.value = true
@@ -46,4 +48,3 @@ export function useStagingFinalize() {
return { result, loading, error, finalize, reset }
}
@@ -11,14 +11,28 @@ const changeListeners = new Set<() => void>()
let fetchPromise: Promise<void> | null = null
let loaded = false
async function restFetchSummary() {
function requestSummary(markLoading: boolean): Promise<void> {
if (fetchPromise) return fetchPromise
try {
const res = await api('/api/v1/staging/summary')
if (res.ok) summary.value = await res.json()
} catch {
// best-effort refresh on reconnect
}
if (markLoading) loading.value = true
fetchPromise = api('/api/v1/staging/summary')
.then(async (res) => {
if (res.ok) {
summary.value = await res.json()
loaded = true
}
})
.catch(() => {
// best-effort refresh on reconnect
})
.finally(() => {
if (markLoading) loading.value = false
fetchPromise = null
})
return fetchPromise
}
async function restFetchSummary() {
return requestSummary(false)
}
function getSocket(): Socket {
@@ -49,22 +63,9 @@ function getSocket(): Socket {
}
export function useStagingSummary() {
async function fetchSummary(): Promise<void> {
if (loaded) return
if (fetchPromise) return fetchPromise
loading.value = true
fetchPromise = api('/api/v1/staging/summary')
.then(async (res) => {
if (res.ok) {
summary.value = await res.json()
loaded = true
}
})
.finally(() => {
loading.value = false
fetchPromise = null
})
return fetchPromise
async function fetchSummary(force = false): Promise<void> {
if (!force && loaded) return
return requestSummary(true)
}
function subscribe() {
+11
View File
@@ -24,6 +24,7 @@ import { useBookNavigation } from '@/features/book/composables/useBookNavigation
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
import type { GroupRule, SortSpec } from '@projectx/types'
@@ -39,6 +40,7 @@ const title = computed(() => currentLibrary.value?.name ?? 'Library')
const libraryIcon = computed(() => currentLibrary.value?.icon ?? 'BookOpen')
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { setBookContext, registerLoadMore } = useBookNavigation()
watch(
@@ -199,6 +201,15 @@ onMounted(() => {
onUnmounted(() => observer?.disconnect())
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount === 0) return
if (libraryId.value === event.libraryId) {
load(true)
}
})
onUnmounted(() => stopUploadCompletedListener())
watch(libraryId, (newId) => {
if (newId === null) clear()
})
+120 -16
View File
@@ -13,6 +13,14 @@ 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'
type ApplyFetchedResult = {
total: number
applied: number
skipped: number
skippedEdited: number
}
const {
items,
@@ -40,15 +48,19 @@ const { addFiles, isUploading } = useStagingUpload()
const selectedFile = ref<StagingFile | null>(null)
const showFinalizeDialog = ref(false)
const showBulkEditDialog = ref(false)
const showSetDestinationDialog = ref(false)
const dragOver = ref(false)
const newFilesDetected = ref(false)
const applyFetchedResult = ref<{ applied: number; skipped: number; skippedEdited: number } | null>(null)
const namePreviewByFileId = ref<Record<number, string>>({})
const applyFetchedResult = ref<ApplyFetchedResult | null>(null)
const rescanFailed = ref(false)
const retryQueued = ref<number | null>(null)
let newFilesTimer: ReturnType<typeof setTimeout> | null = null
let applyFetchedTimer: ReturnType<typeof setTimeout> | null = null
let rescanFailedTimer: ReturnType<typeof setTimeout> | null = null
let retryQueuedTimer: ReturnType<typeof setTimeout> | null = null
let namePreviewTimer: ReturnType<typeof setTimeout> | null = null
let namePreviewReqSeq = 0
let prevTotal = -1
@@ -80,7 +92,7 @@ function onDiscarded() {
function refresh() {
fetchFiles()
fetchSummary()
fetchSummary(true)
void fetchStatistics()
}
@@ -88,6 +100,7 @@ function onFileUpdated(updated: StagingFile) {
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
scheduleVisibleNamePreview()
}
function hasContent(obj: Record<string, unknown> | null | undefined): boolean {
@@ -131,7 +144,12 @@ async function handleApplyFetched() {
})
if (res.ok) {
const result = await res.json()
applyFetchedResult.value = { applied: result.applied, skipped: result.skipped, skippedEdited: result.skippedEdited ?? 0 }
applyFetchedResult.value = {
total: result.total ?? 0,
applied: result.applied ?? 0,
skipped: result.skipped ?? 0,
skippedEdited: result.skippedEdited ?? 0,
}
if (applyFetchedTimer) clearTimeout(applyFetchedTimer)
applyFetchedTimer = setTimeout(() => {
applyFetchedResult.value = null
@@ -160,17 +178,48 @@ 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/${fileId}`, {
method: 'PATCH',
const res = await api('/api/v1/staging/files/apply-fetched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selectedMetadata: file.fetchedMetadata }),
body: JSON.stringify({ fileIds: [fileId] }),
})
if (res.ok) {
const updated: StagingFile = await res.json()
onFileUpdated(updated)
const result = await res.json()
applyFetchedResult.value = {
total: result.total ?? 0,
applied: result.applied ?? 0,
skipped: result.skipped ?? 0,
skippedEdited: result.skippedEdited ?? 0,
}
if (applyFetchedTimer) clearTimeout(applyFetchedTimer)
applyFetchedTimer = setTimeout(() => {
applyFetchedResult.value = null
}, 4000)
refresh()
scheduleVisibleNamePreview()
}
}
function pluralizeFiles(count: number): string {
return `${count} file${count === 1 ? '' : 's'}`
}
function applyFetchedResultMessage(result: ApplyFetchedResult): string {
const reasons: string[] = []
if (result.skippedEdited > 0)
reasons.push(`skipped ${pluralizeFiles(result.skippedEdited)} because ${result.skippedEdited === 1 ? 'it has' : 'they have'} manual edits`)
if (result.skipped > 0)
reasons.push(`skipped ${pluralizeFiles(result.skipped)} because ${result.skipped === 1 ? 'it has' : 'they have'} no fetched metadata`)
if (result.applied > 0) {
const base = `Applied to ${pluralizeFiles(result.applied)}`
return reasons.length ? `${base}; ${reasons.join('; ')}` : base
}
if (reasons.length) return `No files applied; ${reasons.join('; ')}`
return 'No files selected'
}
function handleRescanError() {
rescanFailed.value = true
if (rescanFailedTimer) clearTimeout(rescanFailedTimer)
@@ -185,6 +234,10 @@ function openBulkEdit() {
showBulkEditDialog.value = true
}
function openSetDestination() {
showSetDestinationDialog.value = true
}
function onFinalized() {
showFinalizeDialog.value = false
clearSelection()
@@ -197,6 +250,43 @@ function onBulkEdited() {
refresh()
}
function onDestinationSet() {
showSetDestinationDialog.value = false
refresh()
scheduleVisibleNamePreview()
}
async function fetchVisibleNamePreview() {
const ids = items.value.map((f) => f.id)
if (!ids.length) {
namePreviewByFileId.value = {}
return
}
const reqId = ++namePreviewReqSeq
try {
const res = await api('/api/v1/staging/files/preview-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileIds: ids }),
})
if (!res.ok || reqId !== namePreviewReqSeq) return
const rows: { fileId: number; newName: string }[] = await res.json()
if (reqId !== namePreviewReqSeq) return
namePreviewByFileId.value = Object.fromEntries(rows.map((r) => [r.fileId, r.newName]))
} catch {
if (reqId !== namePreviewReqSeq) return
namePreviewByFileId.value = {}
}
}
function scheduleVisibleNamePreview() {
if (namePreviewTimer) clearTimeout(namePreviewTimer)
namePreviewTimer = setTimeout(() => {
void fetchVisibleNamePreview()
}, 120)
}
function onDragOver(e: DragEvent) {
e.preventDefault()
}
@@ -210,7 +300,7 @@ function onDragEnter(e: DragEvent) {
}
function onDragLeave() {
dragCounter--
dragCounter = Math.max(0, dragCounter - 1)
if (dragCounter === 0) dragOver.value = false
}
@@ -234,9 +324,16 @@ watch(isUploading, (uploading, was) => {
if (was && !uploading) refresh()
})
watch(
() => items.value.map((f) => `${f.id}:${f.updatedAt}:${f.targetLibraryId ?? ''}:${f.targetFolderId ?? ''}`).join('|'),
() => {
scheduleVisibleNamePreview()
},
)
onMounted(() => {
fetchFiles()
fetchSummary()
fetchSummary(true)
fetchStatistics()
subscribe()
})
@@ -247,12 +344,13 @@ onUnmounted(() => {
if (applyFetchedTimer) clearTimeout(applyFetchedTimer)
if (rescanFailedTimer) clearTimeout(rescanFailedTimer)
if (retryQueuedTimer) clearTimeout(retryQueuedTimer)
if (namePreviewTimer) clearTimeout(namePreviewTimer)
})
</script>
<template>
<main class="flex-1" @dragover="onDragOver" @dragenter="onDragEnter" @dragleave="onDragLeave" @drop="onDrop">
<div class="flex flex-col gap-4 p-4 sm:p-6 max-w-7xl w-full">
<div class="flex flex-col gap-4 p-4 sm:p-6 max-w-8xl w-full">
<div class="flex items-center gap-2.5">
<div class="flex items-center justify-center size-9 rounded-lg bg-primary/10">
<PackageOpen class="size-4.5 text-primary" />
@@ -279,11 +377,7 @@ onUnmounted(() => {
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400 text-xs font-medium"
>
<CheckCircle2 class="size-3.5" />
{{
applyFetchedResult.applied === 0
? 'No fetched metadata to apply'
: `Applied to ${applyFetchedResult.applied} file${applyFetchedResult.applied !== 1 ? 's' : ''}${applyFetchedResult.skippedEdited > 0 ? `, skipped ${applyFetchedResult.skippedEdited} with manual edits` : ''}`
}}
{{ applyFetchedResultMessage(applyFetchedResult) }}
</span>
</Transition>
<Transition name="fade">
@@ -329,6 +423,7 @@ onUnmounted(() => {
@bulk-discard="handleBulkDiscard"
@finalize="openFinalize"
@bulk-edit="openBulkEdit"
@set-destination="openSetDestination"
@refresh="refresh"
@apply-fetched="handleApplyFetched"
/>
@@ -360,6 +455,7 @@ onUnmounted(() => {
:loading="loading"
:is-selected="isSelected"
:select-all="selectAll"
:name-preview-by-file-id="namePreviewByFileId"
:empty-message="emptyMessage"
@select="toggleSelect"
@select-all="toggleSelectAll"
@@ -400,6 +496,14 @@ onUnmounted(() => {
@close="showBulkEditDialog = false"
@edited="onBulkEdited"
/>
<StagingSetDestinationDialog
v-if="showSetDestinationDialog"
:selection-payload="getSelectionPayload()"
:selection-count="selectionCount"
@close="showSetDestinationDialog = false"
@updated="onDestinationSet"
/>
</template>
<style scoped>