mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(scanner): improve staging metadata and destination finalize flow
This commit is contained in:
@@ -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 }]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user