310 lines
7.7 KiB
Vue
310 lines
7.7 KiB
Vue
<template>
|
|
<div class="upload-view">
|
|
<h1 class="page-title">Book Library</h1>
|
|
<p v-if="uploadEnabled" class="page-subtitle">Upload medical textbooks (PDF) to build the knowledge base.</p>
|
|
|
|
<!-- Upload Section -->
|
|
<div v-if="uploadEnabled" class="upload-section card">
|
|
<h2 class="section-title">Upload a Book</h2>
|
|
|
|
<div
|
|
class="drop-zone"
|
|
:class="{ 'drop-zone--active': isDragging, 'drop-zone--has-file': selectedFile }"
|
|
@dragover.prevent="isDragging = true"
|
|
@dragleave="isDragging = false"
|
|
@drop.prevent="onDrop"
|
|
@click="fileInput?.click()"
|
|
>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
accept=".pdf,application/pdf"
|
|
class="file-input-hidden"
|
|
@change="onFileChange"
|
|
/>
|
|
<div v-if="!selectedFile" class="drop-zone-content">
|
|
<span class="drop-zone-icon">📄</span>
|
|
<p class="drop-zone-text">Drop a PDF here or click to browse</p>
|
|
<p class="drop-zone-hint">Maximum size: 100 MB</p>
|
|
</div>
|
|
<div v-else class="drop-zone-selected">
|
|
<span class="drop-zone-icon">✅</span>
|
|
<p class="drop-zone-text">{{ selectedFile.name }}</p>
|
|
<p class="drop-zone-hint">{{ formatFileSize(selectedFile.size) }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="upload-controls">
|
|
<button
|
|
class="btn btn-secondary"
|
|
:disabled="!selectedFile || bookStore.uploading"
|
|
@click="clearFile"
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
class="btn btn-primary"
|
|
:disabled="!selectedFile || bookStore.uploading"
|
|
@click="handleUpload"
|
|
>
|
|
<span v-if="bookStore.uploading" class="spinner"></span>
|
|
{{ bookStore.uploading ? 'Uploading...' : 'Upload' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="uploadSuccess" class="upload-feedback upload-feedback--success">
|
|
Book uploaded successfully — embedding will start shortly.
|
|
</div>
|
|
<div v-if="uploadError" class="upload-feedback upload-feedback--error">
|
|
{{ uploadError }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Book List -->
|
|
<div class="book-list-section">
|
|
<div class="book-list-header">
|
|
<h2 class="section-title">Uploaded Books</h2>
|
|
<button class="btn btn-secondary" :disabled="bookStore.loading" @click="bookStore.fetchBooks()">
|
|
<span v-if="bookStore.loading" class="spinner spinner-dark"></span>
|
|
{{ bookStore.loading ? 'Refreshing...' : 'Refresh' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="bookStore.loading && bookStore.books.length === 0" class="empty-state">
|
|
<div class="spinner spinner-dark" style="width:32px;height:32px;margin:0 auto 1rem;"></div>
|
|
<p class="empty-state-text">Loading library...</p>
|
|
</div>
|
|
|
|
<div v-else-if="bookStore.books.length === 0" class="empty-state">
|
|
<div class="empty-state-icon">📚</div>
|
|
<p class="empty-state-text">No books uploaded yet</p>
|
|
<p class="empty-state-hint">Upload a medical textbook PDF to get started.</p>
|
|
</div>
|
|
|
|
<div v-else class="book-grid">
|
|
<BookCard
|
|
v-for="book in bookStore.books"
|
|
:key="book.id"
|
|
:book="book"
|
|
:deleting="deletingId === book.id"
|
|
:delete-enabled="deleteEnabled"
|
|
@delete="handleDelete"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, inject } from 'vue'
|
|
import { useBookStore } from '@/stores/bookStore'
|
|
import BookCard from '@/components/BookCard.vue'
|
|
import { env } from '@/env'
|
|
|
|
const uploadEnabled = env('VITE_UPLOAD_ENABLED') !== 'false'
|
|
const deleteEnabled = env('VITE_DELETE_ENABLED') !== 'false'
|
|
|
|
const bookStore = useBookStore()
|
|
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
|
|
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
const selectedFile = ref<File | null>(null)
|
|
const isDragging = ref(false)
|
|
const uploadSuccess = ref(false)
|
|
const uploadError = ref<string | null>(null)
|
|
const deletingId = ref<string | null>(null)
|
|
|
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
onMounted(async () => {
|
|
await bookStore.fetchBooks()
|
|
startPolling()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
stopPolling()
|
|
})
|
|
|
|
function startPolling() {
|
|
pollInterval = setInterval(async () => {
|
|
const hasActive = bookStore.books.some(
|
|
(b) => b.status === 'PENDING' || b.status === 'PROCESSING'
|
|
)
|
|
if (hasActive) {
|
|
await bookStore.fetchBooks()
|
|
}
|
|
}, 5000)
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollInterval != null) {
|
|
clearInterval(pollInterval)
|
|
pollInterval = null
|
|
}
|
|
}
|
|
|
|
function onFileChange(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
if (input.files && input.files.length > 0) {
|
|
setFile(input.files[0])
|
|
}
|
|
}
|
|
|
|
function onDrop(event: DragEvent) {
|
|
isDragging.value = false
|
|
const file = event.dataTransfer?.files[0]
|
|
if (file) setFile(file)
|
|
}
|
|
|
|
function setFile(file: File) {
|
|
if (!file.name.toLowerCase().endsWith('.pdf')) {
|
|
uploadError.value = 'Only PDF files are accepted.'
|
|
return
|
|
}
|
|
selectedFile.value = file
|
|
uploadError.value = null
|
|
uploadSuccess.value = false
|
|
}
|
|
|
|
function clearFile() {
|
|
selectedFile.value = null
|
|
uploadError.value = null
|
|
uploadSuccess.value = false
|
|
if (fileInput.value) fileInput.value.value = ''
|
|
}
|
|
|
|
async function handleUpload() {
|
|
if (!selectedFile.value) return
|
|
uploadError.value = null
|
|
uploadSuccess.value = false
|
|
|
|
const result = await bookStore.uploadBook(selectedFile.value)
|
|
if (result) {
|
|
uploadSuccess.value = true
|
|
clearFile()
|
|
showToast?.('Book uploaded — embedding started.', 'success')
|
|
} else {
|
|
uploadError.value = bookStore.error ?? 'Upload failed.'
|
|
showToast?.(uploadError.value, 'error')
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
deletingId.value = id
|
|
const ok = await bookStore.deleteBook(id)
|
|
deletingId.value = null
|
|
if (!ok) {
|
|
showToast?.(bookStore.error ?? 'Delete failed.', 'error')
|
|
}
|
|
}
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.upload-section {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
color: #2d3748;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.drop-zone {
|
|
border: 2px dashed #cbd5e0;
|
|
border-radius: 8px;
|
|
padding: 2rem;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.drop-zone:hover,
|
|
.drop-zone--active {
|
|
border-color: #3182ce;
|
|
background: #ebf8ff;
|
|
}
|
|
|
|
.drop-zone--has-file {
|
|
border-color: #48bb78;
|
|
background: #f0fff4;
|
|
}
|
|
|
|
.file-input-hidden {
|
|
display: none;
|
|
}
|
|
|
|
.drop-zone-icon {
|
|
font-size: 2rem;
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.drop-zone-content,
|
|
.drop-zone-selected {
|
|
pointer-events: none;
|
|
}
|
|
|
|
.drop-zone-text {
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
color: #4a5568;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.drop-zone-hint {
|
|
font-size: 0.85rem;
|
|
color: #a0aec0;
|
|
}
|
|
|
|
.upload-controls {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.upload-feedback {
|
|
margin-top: 0.75rem;
|
|
padding: 0.6rem 1rem;
|
|
border-radius: 6px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.upload-feedback--success {
|
|
background: #c6f6d5;
|
|
color: #22543d;
|
|
border: 1px solid #68d391;
|
|
}
|
|
|
|
.upload-feedback--error {
|
|
background: #fed7d7;
|
|
color: #742a2a;
|
|
border: 1px solid #fc8181;
|
|
}
|
|
|
|
.book-list-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.book-list-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.book-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
</style>
|