265 lines
6.1 KiB
Vue
265 lines
6.1 KiB
Vue
<template>
|
|
<div class="book-card card">
|
|
<div class="book-header">
|
|
<div class="book-info">
|
|
<h3 class="book-title">{{ book.title }}</h3>
|
|
<p class="book-filename">{{ book.fileName }}</p>
|
|
</div>
|
|
<span class="status-badge" :class="statusClass">{{ book.status }}</span>
|
|
</div>
|
|
|
|
<div class="book-meta">
|
|
<span v-if="book.fileSizeBytes" class="meta-item">
|
|
{{ formatFileSize(book.fileSizeBytes) }}
|
|
</span>
|
|
<span v-if="book.pageCount" class="meta-item">
|
|
{{ book.pageCount }} pages
|
|
</span>
|
|
<span class="meta-item">
|
|
Uploaded {{ formatDate(book.uploadedAt) }}
|
|
</span>
|
|
<span v-if="book.processedAt" class="meta-item">
|
|
Processed {{ formatDate(book.processedAt) }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="book.status === 'FAILED' && book.errorMessage" class="error-message">
|
|
<strong>Error:</strong> {{ book.errorMessage }}
|
|
</div>
|
|
|
|
<div v-if="book.status === 'PENDING' || book.status === 'PROCESSING'" class="processing-indicator">
|
|
<div class="spinner spinner-dark"></div>
|
|
<span>{{ book.status === 'PENDING' ? 'Queued for processing...' : 'Embedding in progress...' }}</span>
|
|
</div>
|
|
|
|
<div v-if="enrichProgress && enrichProgress.status === 'RUNNING'" class="processing-indicator">
|
|
<div class="spinner spinner-dark"></div>
|
|
<span>Enriching chunks {{ enrichProgress.chunksEnriched }} / {{ enrichProgress.chunksTotal }}</span>
|
|
</div>
|
|
|
|
<div v-if="enrichFeedback" class="enrich-feedback">{{ enrichFeedback }}</div>
|
|
|
|
<div class="book-actions">
|
|
<router-link
|
|
v-if="book.status === 'READY'"
|
|
:to="{ name: 'book-reader', params: { id: book.id } }"
|
|
class="btn btn-secondary"
|
|
>
|
|
Read
|
|
</router-link>
|
|
<button
|
|
v-if="book.status === 'READY' && uploadEnabled"
|
|
class="btn btn-secondary"
|
|
:disabled="enrichRunning"
|
|
@click="handleEnrich"
|
|
title="Enrich chunks with concept metadata"
|
|
>
|
|
{{ enrichRunning ? 'Enriching...' : 'Enrich' }}
|
|
</button>
|
|
<button
|
|
v-if="deleteEnabled"
|
|
class="btn btn-danger"
|
|
:disabled="book.status === 'PROCESSING' || deleting"
|
|
@click="$emit('delete', book.id)"
|
|
:title="book.status === 'PROCESSING' ? 'Cannot delete while processing' : 'Delete book'"
|
|
>
|
|
{{ deleting ? 'Deleting...' : 'Delete' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onUnmounted, ref } from 'vue'
|
|
import type { Book, EnrichmentProgress } from '@/stores/bookStore'
|
|
import { useBookStore } from '@/stores/bookStore'
|
|
import { env } from '@/env';
|
|
|
|
const props = defineProps<{
|
|
book: Book
|
|
deleting?: boolean
|
|
deleteEnabled?: boolean
|
|
}>()
|
|
|
|
defineEmits<{
|
|
(e: 'delete', id: string): void
|
|
}>()
|
|
|
|
const bookStore = useBookStore()
|
|
const enrichProgress = ref<EnrichmentProgress | null>(null)
|
|
const enrichFeedback = ref<string | null>(null)
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
const enrichRunning = computed(() => enrichProgress.value?.status === 'RUNNING')
|
|
const uploadEnabled = env('VITE_UPLOAD_ENABLED') !== 'false'
|
|
|
|
async function handleEnrich() {
|
|
enrichFeedback.value = null
|
|
const started = await bookStore.startEnrichment(props.book.id)
|
|
if (!started) {
|
|
enrichFeedback.value = bookStore.error ?? 'Enrichment failed to start.'
|
|
return
|
|
}
|
|
enrichProgress.value = started
|
|
startPolling()
|
|
}
|
|
|
|
function startPolling() {
|
|
stopPolling()
|
|
pollTimer = setInterval(async () => {
|
|
const status = await bookStore.fetchEnrichmentStatus(props.book.id)
|
|
if (!status) return
|
|
enrichProgress.value = status
|
|
if (status.status === 'COMPLETED') {
|
|
stopPolling()
|
|
enrichFeedback.value = `Enriched ${status.chunksEnriched} / ${status.chunksTotal} chunks.`
|
|
}
|
|
}, 2000)
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer != null) {
|
|
clearInterval(pollTimer)
|
|
pollTimer = null
|
|
}
|
|
}
|
|
|
|
onUnmounted(stopPolling)
|
|
|
|
const statusClass = computed(() => {
|
|
switch (props.book.status) {
|
|
case 'READY':
|
|
return 'status-ready'
|
|
case 'PROCESSING':
|
|
return 'status-processing'
|
|
case 'PENDING':
|
|
return 'status-pending'
|
|
case 'FAILED':
|
|
return 'status-failed'
|
|
default:
|
|
return ''
|
|
}
|
|
})
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleString()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.book-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.book-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.book-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.book-title {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: #1a365d;
|
|
word-break: break-word;
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
|
|
.book-filename {
|
|
font-size: 0.8rem;
|
|
color: #718096;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.status-badge {
|
|
flex-shrink: 0;
|
|
padding: 0.25rem 0.65rem;
|
|
border-radius: 999px;
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.status-ready {
|
|
background: #c6f6d5;
|
|
color: #22543d;
|
|
}
|
|
|
|
.status-processing {
|
|
background: #bee3f8;
|
|
color: #1a365d;
|
|
}
|
|
|
|
.status-pending {
|
|
background: #fefcbf;
|
|
color: #744210;
|
|
}
|
|
|
|
.status-failed {
|
|
background: #fed7d7;
|
|
color: #742a2a;
|
|
}
|
|
|
|
.book-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.meta-item {
|
|
font-size: 0.8rem;
|
|
color: #718096;
|
|
background: #f7fafc;
|
|
padding: 0.2rem 0.5rem;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.error-message {
|
|
font-size: 0.85rem;
|
|
color: #742a2a;
|
|
background: #fff5f5;
|
|
border: 1px solid #fed7d7;
|
|
border-radius: 6px;
|
|
padding: 0.5rem 0.75rem;
|
|
}
|
|
|
|
.processing-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.85rem;
|
|
color: #2b6cb0;
|
|
}
|
|
|
|
.book-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.enrich-feedback {
|
|
font-size: 0.8rem;
|
|
color: #22543d;
|
|
background: #f0fff4;
|
|
border: 1px solid #c6f6d5;
|
|
border-radius: 6px;
|
|
padding: 0.4rem 0.6rem;
|
|
}
|
|
</style>
|