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:
ggq-admin 2025-10-20 01:55:28 +02:00
parent c8505c31d4
commit fcd6fcf091
5 changed files with 170 additions and 3 deletions

View file

@ -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>

View 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>

View file

@ -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
}

View file

@ -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

View 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,
}
}