Implementar sistema de toast con detección de PWA
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 27s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 27s
Cambios: - Crear componente Toast.vue con soporte para posiciones (top/bottom, left/center/right) - Crear composable useToast.js para manejar notificaciones - Integrar sistema de toast en App.vue - Implementar detección de PWA: * Detecta si el usuario está en modo standalone (PWA instalada) * Si puede instalar, muestra toast con botón de instalación * Si ya está instalada pero no se usa, sugiere abrir en app - Toast persistente hasta que el usuario interactúe - Soporte para tema claro/oscuro - Animaciones suaves y diseño moderno - Responsive para móviles El sistema permite mostrar toasts de tipo: success, error, warning, info, pwa con opciones de posición, duración, acciones personalizadas y modo persistente.
This commit is contained in:
@@ -156,6 +156,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Toast System -->
|
||||||
|
<Toast position="top-center" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -168,6 +171,12 @@ import UserForm from './components/UserForm.vue';
|
|||||||
import RawDbViewer from './components/RawDbViewer.vue';
|
import RawDbViewer from './components/RawDbViewer.vue';
|
||||||
import VlanForm from './components/VlanForm.vue';
|
import VlanForm from './components/VlanForm.vue';
|
||||||
import DeviceForm from './components/DeviceForm.vue';
|
import DeviceForm from './components/DeviceForm.vue';
|
||||||
|
import Toast from './components/Toast.vue';
|
||||||
|
import { createToastSystem, useToast } from './composables/useToast.js';
|
||||||
|
|
||||||
|
// Initialize toast system
|
||||||
|
createToastSystem();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const users = ref([]);
|
const users = ref([]);
|
||||||
const requests = ref([]);
|
const requests = ref([]);
|
||||||
@@ -292,8 +301,74 @@ onMounted(async () => {
|
|||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
setupSse();
|
setupSse();
|
||||||
applyTheme();
|
applyTheme();
|
||||||
|
checkPWAStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PWA Detection and Toast
|
||||||
|
function checkPWAStatus() {
|
||||||
|
// Don't show if already dismissed
|
||||||
|
if (localStorage.getItem('pwa_toast_dismissed')) return;
|
||||||
|
|
||||||
|
// Check if running in standalone mode (PWA installed and active)
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
window.navigator.standalone === true;
|
||||||
|
|
||||||
|
// If NOT in standalone mode, user is in browser
|
||||||
|
if (!isStandalone) {
|
||||||
|
// Check if PWA can be installed
|
||||||
|
let deferredPrompt = null;
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
|
||||||
|
// Show install prompt
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.pwa('Instala RADIUS Nucleo como aplicación para una mejor experiencia', {
|
||||||
|
title: '📱 Instalar Aplicación',
|
||||||
|
position: 'top-center',
|
||||||
|
duration: 0, // Persistent
|
||||||
|
persistent: true,
|
||||||
|
action: {
|
||||||
|
label: 'Instalar',
|
||||||
|
handler: async () => {
|
||||||
|
if (deferredPrompt) {
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
localStorage.setItem('pwa_toast_dismissed', 'true');
|
||||||
|
}
|
||||||
|
deferredPrompt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If already installed but not in standalone, prompt to open in app
|
||||||
|
if ('getInstalledRelatedApps' in navigator) {
|
||||||
|
navigator.getInstalledRelatedApps().then((apps) => {
|
||||||
|
if (apps.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.pwa('Abre RADIUS Nucleo en la aplicación para una mejor experiencia', {
|
||||||
|
title: '📱 Abrir en App',
|
||||||
|
position: 'top-center',
|
||||||
|
duration: 10000,
|
||||||
|
action: {
|
||||||
|
label: 'Entendido',
|
||||||
|
handler: () => {
|
||||||
|
localStorage.setItem('pwa_toast_dismissed', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filteredRequests = computed(() => {
|
const filteredRequests = computed(() => {
|
||||||
return requests.value.filter(ev => {
|
return requests.value.filter(ev => {
|
||||||
if (eventFilters.type && ev.type !== eventFilters.type) return false;
|
if (eventFilters.type && ev.type !== eventFilters.type) return false;
|
||||||
|
|||||||
287
frontend/src/components/Toast.vue
Normal file
287
frontend/src/components/Toast.vue
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div :class="['toast-container', position]">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div
|
||||||
|
v-for="toast in toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
:class="['toast', toast.type]"
|
||||||
|
@click="removeToast(toast.id)"
|
||||||
|
>
|
||||||
|
<div class="toast-content">
|
||||||
|
<div class="toast-icon">{{ getIcon(toast.type) }}</div>
|
||||||
|
<div class="toast-text">
|
||||||
|
<div v-if="toast.title" class="toast-title">{{ toast.title }}</div>
|
||||||
|
<div class="toast-message">{{ toast.message }}</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="toast.action" class="toast-action" @click.stop="toast.action.handler">
|
||||||
|
{{ toast.action.label }}
|
||||||
|
</button>
|
||||||
|
<button v-if="!toast.persistent" class="toast-close" @click.stop="removeToast(toast.id)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
position: {
|
||||||
|
type: String,
|
||||||
|
default: 'top-right',
|
||||||
|
validator: (v) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(v)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toasts = inject('toasts');
|
||||||
|
const removeToast = inject('removeToast');
|
||||||
|
|
||||||
|
function getIcon(type) {
|
||||||
|
const icons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
warning: '⚠',
|
||||||
|
info: 'ℹ',
|
||||||
|
pwa: '📱'
|
||||||
|
};
|
||||||
|
return icons[type] || 'ℹ';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container.top-left {
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container.top-center {
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container.top-right {
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container.bottom-left {
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container.bottom-center {
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container.bottom-right {
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(30, 30, 30, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
border-left: 4px solid #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
border-left: 4px solid #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.warning {
|
||||||
|
border-left: 4px solid #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
border-left: 4px solid #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.pwa {
|
||||||
|
border-left: 4px solid #a78bfa;
|
||||||
|
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(30, 30, 30, 0.95) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-action {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-action:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
.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) scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top center animations */
|
||||||
|
.toast-container.top-center .toast-enter-from {
|
||||||
|
transform: translateY(-100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container.top-center .toast-leave-to {
|
||||||
|
transform: translateY(-100px) scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme adjustments */
|
||||||
|
:global(.light) .toast {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.light) .toast-title {
|
||||||
|
color: rgba(0, 0, 0, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.light) .toast-message {
|
||||||
|
color: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.light) .toast-icon {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.light) .toast-action {
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
color: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.light) .toast-action:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.light) .toast-close {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.light) .toast-close:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
color: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.toast-container {
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
left: 20px !important;
|
||||||
|
right: 20px !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
frontend/src/composables/useToast.js
Normal file
64
frontend/src/composables/useToast.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { ref, provide, inject } from 'vue';
|
||||||
|
|
||||||
|
const toasts = ref([]);
|
||||||
|
let toastId = 0;
|
||||||
|
|
||||||
|
export function createToastSystem() {
|
||||||
|
const removeToast = (id) => {
|
||||||
|
const index = toasts.value.findIndex(t => t.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
toasts.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToast = (options) => {
|
||||||
|
const id = ++toastId;
|
||||||
|
const toast = {
|
||||||
|
id,
|
||||||
|
message: options.message || '',
|
||||||
|
title: options.title || '',
|
||||||
|
type: options.type || 'info',
|
||||||
|
position: options.position || 'top-right',
|
||||||
|
duration: options.duration ?? 5000,
|
||||||
|
persistent: options.persistent || false,
|
||||||
|
action: options.action || null
|
||||||
|
};
|
||||||
|
|
||||||
|
toasts.value.push(toast);
|
||||||
|
|
||||||
|
if (!toast.persistent && toast.duration > 0) {
|
||||||
|
setTimeout(() => removeToast(id), toast.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
provide('toasts', toasts);
|
||||||
|
provide('removeToast', removeToast);
|
||||||
|
provide('addToast', addToast);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toasts,
|
||||||
|
addToast,
|
||||||
|
removeToast
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const addToast = inject('addToast');
|
||||||
|
const removeToast = inject('removeToast');
|
||||||
|
|
||||||
|
const toast = {
|
||||||
|
success: (message, options = {}) => addToast({ message, type: 'success', ...options }),
|
||||||
|
error: (message, options = {}) => addToast({ message, type: 'error', ...options }),
|
||||||
|
warning: (message, options = {}) => addToast({ message, type: 'warning', ...options }),
|
||||||
|
info: (message, options = {}) => addToast({ message, type: 'info', ...options }),
|
||||||
|
pwa: (message, options = {}) => addToast({ message, type: 'pwa', ...options }),
|
||||||
|
custom: (options) => addToast(options)
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
toast,
|
||||||
|
removeToast
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user