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>
|
<template>
|
||||||
<div id="app" class="min-h-screen">
|
<div id="app" class="min-h-screen">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import ToastContainer from './components/ToastContainer.vue'
|
||||||
</script>
|
</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">
|
<div class="flex-1">
|
||||||
<p class="font-medium text-white">{{ selectedFile.name }}</p>
|
<p class="font-medium text-white">{{ selectedFile.name }}</p>
|
||||||
<p class="text-sm text-white/70">{{ formatFileSize(selectedFile.size) }}</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>
|
<div class="spinner border-pink-400" style="width: 12px; height: 12px; border-width: 2px;"></div>
|
||||||
Extracting metadata from first page...
|
Extracting metadata from first page...
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -227,6 +227,7 @@
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useJobPolling } from '../composables/useJobPolling'
|
import { useJobPolling } from '../composables/useJobPolling'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isOpen: {
|
isOpen: {
|
||||||
|
|
@ -238,6 +239,7 @@ const props = defineProps({
|
||||||
const emit = defineEmits(['close', 'upload-success'])
|
const emit = defineEmits(['close', 'upload-success'])
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
const selectedFile = ref(null)
|
const selectedFile = ref(null)
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
|
@ -416,7 +418,7 @@ async function uploadFile() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error)
|
console.error('Upload error:', error)
|
||||||
errorMessage.value = error.message
|
errorMessage.value = error.message
|
||||||
alert(`Upload failed: ${error.message}`)
|
toast.error(`Upload failed: ${error.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, onUnmounted } from 'vue'
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
export function useJobPolling() {
|
export function useJobPolling() {
|
||||||
|
const toast = useToast()
|
||||||
const jobId = ref(null)
|
const jobId = ref(null)
|
||||||
const jobStatus = ref('pending')
|
const jobStatus = ref('pending')
|
||||||
const jobProgress = ref(0)
|
const jobProgress = ref(0)
|
||||||
|
|
@ -45,9 +47,20 @@ export function useJobPolling() {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
const previousStatus = jobStatus.value
|
||||||
jobStatus.value = data.status
|
jobStatus.value = data.status
|
||||||
jobProgress.value = data.progress || 0
|
jobProgress.value = data.progress || 0
|
||||||
jobError.value = data.error || null
|
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 {
|
} else {
|
||||||
console.error('Poll error:', data.error)
|
console.error('Poll error:', data.error)
|
||||||
// Don't stop polling on transient errors
|
// 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