first implementation
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="upload-view">
|
||||
<h1 class="page-title">Book Library</h1>
|
||||
<p class="page-subtitle">Upload medical textbooks (PDF) to build the knowledge base.</p>
|
||||
|
||||
<!-- Upload Section -->
|
||||
<div 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="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'
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user