| 1 |
<template> |
| 2 |
<div class="notification-container fixed top-4 right-4 z-50 space-y-2"> |
| 3 |
<TransitionGroup name="notification" tag="div"> |
| 4 |
<div |
| 5 |
v-for="notification in notifications" |
| 6 |
:key="notification.id" |
| 7 |
class="notification max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4" |
| 8 |
:class="{ |
| 9 |
'border-green-200 dark:border-green-800': notification.type === 'success', |
| 10 |
'border-blue-200 dark:border-blue-800': notification.type === 'info', |
| 11 |
'border-yellow-200 dark:border-yellow-800': notification.type === 'warning', |
| 12 |
'border-red-200 dark:border-red-800': notification.type === 'error' |
| 13 |
}" |
| 14 |
> |
| 15 |
<div class="flex items-start"> |
| 16 |
<!-- Icon --> |
| 17 |
<div class="flex-shrink-0 mr-3"> |
| 18 |
<CheckCircleIcon |
| 19 |
v-if="notification.type === 'success'" |
| 20 |
class="w-5 h-5 text-green-500" |
| 21 |
/> |
| 22 |
<InformationCircleIcon |
| 23 |
v-else-if="notification.type === 'info'" |
| 24 |
class="w-5 h-5 text-blue-500" |
| 25 |
/> |
| 26 |
<ExclamationTriangleIcon |
| 27 |
v-else-if="notification.type === 'warning'" |
| 28 |
class="w-5 h-5 text-yellow-500" |
| 29 |
/> |
| 30 |
<ExclamationCircleIcon |
| 31 |
v-else-if="notification.type === 'error'" |
| 32 |
class="w-5 h-5 text-red-500" |
| 33 |
/> |
| 34 |
</div> |
| 35 |
|
| 36 |
<!-- Content --> |
| 37 |
<div class="flex-1 min-w-0"> |
| 38 |
<h4 |
| 39 |
v-if="notification.title" |
| 40 |
class="text-sm font-medium text-gray-900 dark:text-white" |
| 41 |
> |
| 42 |
{{ notification.title }} |
| 43 |
</h4> |
| 44 |
<p class="text-sm text-gray-600 dark:text-gray-300"> |
| 45 |
{{ notification.message }} |
| 46 |
</p> |
| 47 |
</div> |
| 48 |
|
| 49 |
<!-- Close button --> |
| 50 |
<button |
| 51 |
@click="removeNotification(notification.id)" |
| 52 |
class="flex-shrink-0 ml-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" |
| 53 |
> |
| 54 |
<XMarkIcon class="w-4 h-4" /> |
| 55 |
</button> |
| 56 |
</div> |
| 57 |
|
| 58 |
<!-- Progress bar for auto-dismiss --> |
| 59 |
<div |
| 60 |
v-if="notification.duration && notification.duration > 0" |
| 61 |
class="mt-2 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden" |
| 62 |
> |
| 63 |
<div |
| 64 |
class="h-full bg-gray-400 dark:bg-gray-500 transition-all duration-100 ease-linear" |
| 65 |
:style="`width: ${getProgressWidth(notification)}%`" |
| 66 |
></div> |
| 67 |
</div> |
| 68 |
</div> |
| 69 |
</TransitionGroup> |
| 70 |
</div> |
| 71 |
</template> |
| 72 |
|
| 73 |
<script setup lang="ts"> |
| 74 |
import { ref, onMounted } from 'vue' |
| 75 |
import { |
| 76 |
CheckCircleIcon, |
| 77 |
InformationCircleIcon, |
| 78 |
ExclamationTriangleIcon, |
| 79 |
ExclamationCircleIcon, |
| 80 |
XMarkIcon, |
| 81 |
} from '@heroicons/vue/24/outline' |
| 82 |
|
| 83 |
interface Notification { |
| 84 |
id: string |
| 85 |
type: 'success' | 'error' | 'warning' | 'info' |
| 86 |
title?: string |
| 87 |
message: string |
| 88 |
duration?: number |
| 89 |
createdAt: number |
| 90 |
} |
| 91 |
|
| 92 |
const notifications = ref<Notification[]>([]) |
| 93 |
|
| 94 |
function addNotification(notification: Omit<Notification, 'id' | 'createdAt'>) { |
| 95 |
const id = crypto.randomUUID() |
| 96 |
const newNotification: Notification = { |
| 97 |
...notification, |
| 98 |
id, |
| 99 |
createdAt: Date.now(), |
| 100 |
duration: notification.duration ?? 5000, |
| 101 |
} |
| 102 |
|
| 103 |
notifications.value.push(newNotification) |
| 104 |
|
| 105 |
// Auto-remove after duration |
| 106 |
if (newNotification.duration && newNotification.duration > 0) { |
| 107 |
setTimeout(() => { |
| 108 |
removeNotification(id) |
| 109 |
}, newNotification.duration) |
| 110 |
} |
| 111 |
} |
| 112 |
|
| 113 |
function removeNotification(id: string) { |
| 114 |
const index = notifications.value.findIndex(n => n.id === id) |
| 115 |
if (index > -1) { |
| 116 |
notifications.value.splice(index, 1) |
| 117 |
} |
| 118 |
} |
| 119 |
|
| 120 |
function getProgressWidth(notification: Notification): number { |
| 121 |
if (!notification.duration) return 0 |
| 122 |
|
| 123 |
const elapsed = Date.now() - notification.createdAt |
| 124 |
const progress = Math.max(0, (notification.duration - elapsed) / notification.duration * 100) |
| 125 |
return progress |
| 126 |
} |
| 127 |
|
| 128 |
// Global notification methods |
| 129 |
function showSuccess(message: string, title?: string, duration?: number) { |
| 130 |
addNotification({ type: 'success', title, message, duration }) |
| 131 |
} |
| 132 |
|
| 133 |
function showError(message: string, title?: string, duration?: number) { |
| 134 |
addNotification({ type: 'error', title, message, duration }) |
| 135 |
} |
| 136 |
|
| 137 |
function showWarning(message: string, title?: string, duration?: number) { |
| 138 |
addNotification({ type: 'warning', title, message, duration }) |
| 139 |
} |
| 140 |
|
| 141 |
function showInfo(message: string, title?: string, duration?: number) { |
| 142 |
addNotification({ type: 'info', title, message, duration }) |
| 143 |
} |
| 144 |
|
| 145 |
// Expose methods globally |
| 146 |
onMounted(() => { |
| 147 |
window.$notify = { |
| 148 |
success: showSuccess, |
| 149 |
error: showError, |
| 150 |
warning: showWarning, |
| 151 |
info: showInfo, |
| 152 |
} |
| 153 |
}) |
| 154 |
</script> |
| 155 |
|
| 156 |
<style scoped> |
| 157 |
.notification-enter-active, |
| 158 |
.notification-leave-active { |
| 159 |
transition: all 0.3s ease; |
| 160 |
} |
| 161 |
|
| 162 |
.notification-enter-from { |
| 163 |
opacity: 0; |
| 164 |
transform: translateX(100%); |
| 165 |
} |
| 166 |
|
| 167 |
.notification-leave-to { |
| 168 |
opacity: 0; |
| 169 |
transform: translateX(100%); |
| 170 |
} |
| 171 |
|
| 172 |
.notification-move { |
| 173 |
transition: transform 0.3s ease; |
| 174 |
} |
| 175 |
</style> |