Add toast notification system and improve error handling
- Created useToast composable with success/error/warning/info methods - Added ToastContainer component with animations and colors - Integrated toast notifications throughout the app: * Upload success/failure feedback * OCR completion/failure notifications * Replaced alert() with toast messages - Fixed HTML validation warning (div inside p tag) - Added automatic toast notifications on job status changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c8505c31d4
commit
fcd6fcf091
5 changed files with 170 additions and 3 deletions
|
|
@ -1,9 +1,11 @@
|
|||
<template>
|
||||
<div id="app" class="min-h-screen">
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
import ToastContainer from './components/ToastContainer.vue'
|
||||
</script>
|
||||
|
|
|
|||
86
client/src/components/ToastContainer.vue
Normal file
86
client/src/components/ToastContainer.vue
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<div class="fixed top-4 right-4 z-50 space-y-2 max-w-md">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="[
|
||||
'flex items-start gap-3 p-4 rounded-xl shadow-2xl backdrop-blur-lg border',
|
||||
'animate-slide-in-right',
|
||||
toastClasses[toast.type]
|
||||
]"
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
<svg v-if="toast.type === 'success'" 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else-if="toast.type === 'error'" 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else-if="toast.type === 'warning'" 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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<svg v-else 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium leading-relaxed">{{ toast.message }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="removeToast(toast.id)"
|
||||
class="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<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>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const { toasts, removeToast } = useToast()
|
||||
|
||||
const toastClasses = {
|
||||
success: 'bg-green-500/20 border-green-400/30 text-green-100',
|
||||
error: 'bg-red-500/20 border-red-400/30 text-red-100',
|
||||
warning: 'bg-yellow-500/20 border-yellow-400/30 text-yellow-100',
|
||||
info: 'bg-blue-500/20 border-blue-400/30 text-blue-100',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -57,10 +57,10 @@
|
|||
<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 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>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -227,6 +227,7 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useJobPolling } from '../composables/useJobPolling'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
|
|
@ -238,6 +239,7 @@ const props = defineProps({
|
|||
const emit = defineEmits(['close', 'upload-success'])
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const fileInput = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
const isDragging = ref(false)
|
||||
|
|
@ -416,7 +418,7 @@ async function uploadFile() {
|
|||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
errorMessage.value = error.message
|
||||
alert(`Upload failed: ${error.message}`)
|
||||
toast.error(`Upload failed: ${error.message}`)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@
|
|||
*/
|
||||
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
|
||||
export function useJobPolling() {
|
||||
const toast = useToast()
|
||||
const jobId = ref(null)
|
||||
const jobStatus = ref('pending')
|
||||
const jobProgress = ref(0)
|
||||
|
|
@ -45,9 +47,20 @@ export function useJobPolling() {
|
|||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
const previousStatus = jobStatus.value
|
||||
jobStatus.value = data.status
|
||||
jobProgress.value = data.progress || 0
|
||||
jobError.value = data.error || null
|
||||
|
||||
// Show success toast when job completes
|
||||
if (previousStatus !== 'completed' && data.status === 'completed') {
|
||||
toast.success('Document processed successfully! OCR complete.')
|
||||
}
|
||||
|
||||
// Show error toast when job fails
|
||||
if (previousStatus !== 'failed' && data.status === 'failed') {
|
||||
toast.error(`Processing failed: ${data.error || 'Unknown error'}`)
|
||||
}
|
||||
} else {
|
||||
console.error('Poll error:', data.error)
|
||||
// Don't stop polling on transient errors
|
||||
|
|
|
|||
64
client/src/composables/useToast.js
Normal file
64
client/src/composables/useToast.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
const toasts = ref([])
|
||||
let toastId = 0
|
||||
|
||||
export function useToast() {
|
||||
function showToast(message, type = 'info', duration = 5000) {
|
||||
const id = toastId++
|
||||
const toast = {
|
||||
id,
|
||||
message,
|
||||
type, // 'success', 'error', 'warning', 'info'
|
||||
duration,
|
||||
}
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
const index = toasts.value.findIndex(t => t.id === id)
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function success(message, duration = 4000) {
|
||||
return showToast(message, 'success', duration)
|
||||
}
|
||||
|
||||
function error(message, duration = 6000) {
|
||||
return showToast(message, 'error', duration)
|
||||
}
|
||||
|
||||
function warning(message, duration = 5000) {
|
||||
return showToast(message, 'warning', duration)
|
||||
}
|
||||
|
||||
function info(message, duration = 4000) {
|
||||
return showToast(message, 'info', duration)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
removeToast,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue