navidocs/client/src/components/UploadModal.vue
ggq-admin d461c5742f Fix search, add PDF text selection, clean duplicates, implement auto-fill
This commit addresses multiple critical fixes and adds new functionality
for the NaviDocs local testing environment (port 8083):

Search Fixes:
- Fixed search to use backend /api/search instead of direct Meilisearch
- Resolves network accessibility issue when accessing from external IPs
- Search now works from http://172.29.75.55:8083/search

PDF Text Selection:
- Added PDF.js text layer for selectable text
- Imported pdf_viewer.css for proper text layer styling
- Changed text layer opacity to 1 for better interaction
- Added user-select: text for improved text selection
- Pink selection highlight (rgba(255, 92, 178, 0.3))

Database Cleanup:
- Created cleanup scripts to remove 20 duplicate documents
- Removed 753 orphaned entries from Meilisearch index
- Cleaned 17 document folders from filesystem
- Kept only newest version of each document
- Scripts: clean-duplicates.js, clean-meilisearch-orphans.js

Auto-Fill Feature:
- New /api/upload/quick-ocr endpoint for first-page OCR
- Automatically extracts metadata from PDFs on file selection
- Detects: boat make, model, year, name, and document title
- Checks both OCR text and filename for boat name
- Auto-fills upload form with extracted data
- Shows loading indicator during metadata extraction
- Graceful fallback to filename if OCR fails

Tenant Management:
- Updated organization ID to use boat name as tenant
- Falls back to "Liliane 1" for single-tenant setup
- Each boat becomes a unique tenant in the system

Files Changed:
- client/src/views/DocumentView.vue - Text layer implementation
- client/src/composables/useSearch.js - Backend API integration
- client/src/components/UploadModal.vue - Auto-fill feature
- server/routes/quick-ocr.js - OCR endpoint (new)
- server/index.js - Route registration
- server/scripts/* - Cleanup utilities (new)

Testing:
All features tested on local deployment at http://172.29.75.55:8083
- Backend: http://localhost:8001
- Frontend: http://localhost:8083
- Meilisearch: http://localhost:7700

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 01:35:06 +02:00

481 lines
16 KiB
Vue

<template>
<Transition name="modal">
<div v-if="isOpen" class="modal-overlay" @click.self="closeModal">
<div class="modal-content max-w-3xl">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">Upload Boat Manual</h2>
<button
@click="closeModal"
class="text-white/70 hover:text-pink-400 transition-colors"
aria-label="Close modal"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Upload Form -->
<div v-if="!currentJobId">
<!-- File Drop Zone -->
<div
@drop.prevent="handleDrop"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
:class="[
'border-2 border-dashed rounded-lg p-12 text-center transition-all',
isDragging ? 'border-pink-400 bg-pink-400/10' : 'border-white/20 bg-white/5'
]"
>
<div v-if="!selectedFile">
<svg class="w-16 h-16 mx-auto text-white/50 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-lg text-white mb-2">Drag and drop your PDF here</p>
<p class="text-sm text-white/70 mb-4">or</p>
<label class="btn btn-outline cursor-pointer">
Browse Files
<input
ref="fileInput"
type="file"
accept="application/pdf"
class="hidden"
@change="handleFileSelect"
/>
</label>
<p class="text-xs text-white/70 mt-4">Maximum file size: 50MB</p>
</div>
<!-- Selected File Preview -->
<div v-else class="text-left">
<div class="flex items-center justify-between bg-white/10 backdrop-blur-lg border border-white/20 rounded-lg p-4 shadow-soft">
<div class="flex items-center space-x-3">
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div class="flex-1">
<p class="font-medium text-white">{{ selectedFile.name }}</p>
<p class="text-sm text-white/70">{{ formatFileSize(selectedFile.size) }}</p>
<p v-if="extractingMetadata" class="text-xs text-pink-400 mt-1 flex items-center gap-1">
<div class="spinner border-pink-400" style="width: 12px; height: 12px; border-width: 2px;"></div>
Extracting metadata from first page...
</p>
</div>
</div>
<button
@click="removeFile"
class="text-white/70 hover:text-red-400 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Metadata Form -->
<div v-if="selectedFile" class="mt-6 space-y-4">
<div>
<label class="block text-sm font-medium text-white/70 mb-2">Boat Name</label>
<input
v-model="metadata.boatName"
type="text"
class="input"
placeholder="e.g., Sea Breeze"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-white/70 mb-2">Make</label>
<input
v-model="metadata.boatMake"
type="text"
class="input"
placeholder="e.g., Prestige"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-2">Model</label>
<input
v-model="metadata.boatModel"
type="text"
class="input"
placeholder="e.g., F4.9"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-white/70 mb-2">Year</label>
<input
v-model.number="metadata.boatYear"
type="number"
class="input"
placeholder="e.g., 2024"
min="1900"
:max="new Date().getFullYear()"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-2">Document Type</label>
<select v-model="metadata.documentType" class="input">
<option value="owner-manual">Owner Manual</option>
<option value="component-manual">Component Manual</option>
<option value="service-record">Service Record</option>
<option value="inspection">Inspection Report</option>
<option value="certificate">Certificate</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-2">Title</label>
<input
v-model="metadata.title"
type="text"
class="input"
placeholder="e.g., Electrical System Manual"
/>
</div>
<!-- Upload Button -->
<button
@click="uploadFile"
:disabled="!canUpload"
class="btn btn-primary w-full btn-lg"
:class="{ 'opacity-50 cursor-not-allowed': !canUpload }"
>
<svg v-if="!uploading" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div v-else class="spinner mr-2"></div>
{{ uploading ? 'Uploading...' : 'Upload and Process' }}
</button>
</div>
</div>
<!-- Job Progress -->
<div v-else class="py-8">
<div class="text-center mb-6">
<div class="w-20 h-20 mx-auto mb-4 rounded-full bg-pink-400/20 flex items-center justify-center">
<div v-if="jobStatus !== 'completed'" class="spinner border-pink-400"></div>
<svg v-else class="w-12 h-12 text-success-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 class="text-xl font-semibold text-white mb-2">{{ statusMessage }}</h3>
<p class="text-white/70">{{ statusDescription }}</p>
</div>
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-white/70">Processing</span>
<span class="text-sm font-medium text-white/70">{{ jobProgress }}%</span>
</div>
<div class="w-full bg-white/20 rounded-full h-3 overflow-hidden">
<div
class="bg-gradient-to-r from-pink-400 to-purple-500 h-3 transition-all duration-500 ease-out rounded-full"
:style="{ width: `${jobProgress}%` }"
></div>
</div>
</div>
<!-- Job Info -->
<div class="bg-white/10 backdrop-blur-lg border border-white/20 rounded-lg p-4 text-sm">
<div class="flex justify-between py-2">
<span class="text-white/70">Job ID:</span>
<span class="text-white font-mono">{{ currentJobId.slice(0, 8) }}...</span>
</div>
<div class="flex justify-between py-2">
<span class="text-white/70">Status:</span>
<span class="text-white font-medium capitalize">{{ jobStatus }}</span>
</div>
</div>
<!-- Success Actions -->
<div v-if="jobStatus === 'completed'" class="mt-6 space-y-3">
<button @click="viewDocument" class="btn btn-primary w-full">
View Document
</button>
<button @click="uploadAnother" class="btn btn-outline w-full">
Upload Another Manual
</button>
</div>
<!-- Error Display -->
<div v-if="jobStatus === 'failed'" class="mt-6">
<div class="bg-red-500/10 border-l-4 border-red-400 p-4 rounded">
<p class="text-red-300 font-medium">Processing Failed</p>
<p class="text-red-300/90 text-sm mt-1">{{ errorMessage || 'An error occurred during OCR processing' }}</p>
</div>
<button @click="uploadAnother" class="btn btn-outline w-full mt-4">
Try Again
</button>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useJobPolling } from '../composables/useJobPolling'
const props = defineProps({
isOpen: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'upload-success'])
const router = useRouter()
const fileInput = ref(null)
const selectedFile = ref(null)
const isDragging = ref(false)
const uploading = ref(false)
const currentJobId = ref(null)
const currentDocumentId = ref(null)
const errorMessage = ref(null)
const extractingMetadata = ref(false)
const metadata = ref({
boatName: '',
boatMake: '',
boatModel: '',
boatYear: new Date().getFullYear(),
documentType: 'owner-manual',
title: ''
})
const { jobStatus, jobProgress, startPolling, stopPolling } = useJobPolling()
const canUpload = computed(() => {
return selectedFile.value && metadata.value.title && !uploading.value
})
const statusMessage = computed(() => {
switch (jobStatus.value) {
case 'pending':
return 'Queued for Processing'
case 'processing':
return 'Processing PDF'
case 'completed':
return 'Processing Complete!'
case 'failed':
return 'Processing Failed'
default:
return 'Processing'
}
})
const statusDescription = computed(() => {
switch (jobStatus.value) {
case 'pending':
return 'Your manual is queued and will be processed shortly'
case 'processing':
return 'Extracting text and indexing pages...'
case 'completed':
return 'Your manual is ready to search'
case 'failed':
return 'Something went wrong during processing'
default:
return ''
}
})
async function handleFileSelect(event) {
const file = event.target.files[0]
if (file && file.type === 'application/pdf') {
selectedFile.value = file
// Auto-fill title from filename (fallback)
if (!metadata.value.title) {
metadata.value.title = file.name.replace('.pdf', '')
}
// Trigger quick OCR for metadata extraction
await extractMetadataFromFile(file)
}
}
async function handleDrop(event) {
isDragging.value = false
const file = event.dataTransfer.files[0]
if (file && file.type === 'application/pdf') {
selectedFile.value = file
if (!metadata.value.title) {
metadata.value.title = file.name.replace('.pdf', '')
}
// Trigger quick OCR for metadata extraction
await extractMetadataFromFile(file)
}
}
async function extractMetadataFromFile(file) {
extractingMetadata.value = true
try {
console.log('[Upload Modal] Extracting metadata from first page...')
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload/quick-ocr', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error('Metadata extraction failed')
}
const data = await response.json()
if (data.success && data.metadata) {
console.log('[Upload Modal] Extracted metadata:', data.metadata)
// Auto-fill form fields (only if empty)
if (data.metadata.title && !metadata.value.title) {
metadata.value.title = data.metadata.title
}
if (data.metadata.boatName && !metadata.value.boatName) {
metadata.value.boatName = data.metadata.boatName
}
if (data.metadata.boatMake && !metadata.value.boatMake) {
metadata.value.boatMake = data.metadata.boatMake
}
if (data.metadata.boatModel && !metadata.value.boatModel) {
metadata.value.boatModel = data.metadata.boatModel
}
if (data.metadata.boatYear && !metadata.value.boatYear) {
metadata.value.boatYear = data.metadata.boatYear
}
console.log('[Upload Modal] Form auto-filled with extracted data')
}
} catch (error) {
console.warn('[Upload Modal] Metadata extraction failed:', error)
// Don't show error to user - just fall back to filename
} finally {
extractingMetadata.value = false
}
}
function removeFile() {
selectedFile.value = null
if (fileInput.value) {
fileInput.value.value = ''
}
}
async function uploadFile() {
if (!canUpload.value) return
uploading.value = true
errorMessage.value = null
try {
// Use boat name as organization ID (tenant), fallback to "Liliane 1"
const organizationId = metadata.value.boatName || 'Liliane 1'
const formData = new FormData()
formData.append('file', selectedFile.value) // Use 'file' field name (backend expects this)
formData.append('title', metadata.value.title)
formData.append('documentType', metadata.value.documentType)
formData.append('organizationId', organizationId) // Use boat name as tenant
formData.append('boatName', metadata.value.boatName)
formData.append('boatMake', metadata.value.boatMake)
formData.append('boatModel', metadata.value.boatModel)
formData.append('boatYear', metadata.value.boatYear)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// TODO: Add JWT token header when auth is implemented
// headers: { 'Authorization': `Bearer ${token}` }
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Upload failed')
}
currentJobId.value = data.jobId
currentDocumentId.value = data.documentId
// Start polling for job status
startPolling(data.jobId)
} catch (error) {
console.error('Upload error:', error)
errorMessage.value = error.message
alert(`Upload failed: ${error.message}`)
} finally {
uploading.value = false
}
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function closeModal() {
stopPolling()
emit('close')
}
function viewDocument() {
router.push({
name: 'document',
params: { id: currentDocumentId.value }
})
closeModal()
}
function uploadAnother() {
selectedFile.value = null
currentJobId.value = null
currentDocumentId.value = null
errorMessage.value = null
metadata.value = {
boatName: '',
boatMake: '',
boatModel: '',
boatYear: new Date().getFullYear(),
documentType: 'owner-manual',
title: ''
}
stopPolling()
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .modal-content,
.modal-leave-active .modal-content {
transition: transform 0.3s ease;
}
.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
transform: scale(0.9);
}
</style>