Files
ai-teacher/frontend/src/components/BookCard.vue
T
2026-04-18 19:55:19 +02:00

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>