first implementation

This commit is contained in:
Adrien
2026-03-31 20:58:47 +02:00
parent dc0bcab36e
commit 618e28b354
1878 changed files with 1381732 additions and 5 deletions
+304
View File
@@ -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>