From a0c77d60493330c18512144995f05079e966a2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Wed, 7 Jan 2026 19:37:07 +0100 Subject: [PATCH] Toast service --- assets/app.js | 3 + .../nostr/nostr_publish_controller.js | 49 +++--- .../nostr/nostr_single_sign_controller.js | 13 +- .../controllers/utility/toast_controller.js | 162 ++++++++++++++++++ assets/styles/toast.css | 116 +++++++++++++ templates/components/Toast.html.twig | 18 ++ templates/editor/layout.html.twig | 3 + 7 files changed, 340 insertions(+), 24 deletions(-) create mode 100644 assets/controllers/utility/toast_controller.js create mode 100644 assets/styles/toast.css create mode 100644 templates/components/Toast.html.twig diff --git a/assets/app.js b/assets/app.js index 36ff758..57d577d 100644 --- a/assets/app.js +++ b/assets/app.js @@ -38,6 +38,9 @@ import './styles/03-components/search.css'; import './styles/03-components/image-upload.css'; import './styles/03-components/zaps.css'; +// Toast notifications +import './styles/toast.css'; + // Editor layout import './styles/editor-layout.css'; diff --git a/assets/controllers/nostr/nostr_publish_controller.js b/assets/controllers/nostr/nostr_publish_controller.js index 1ff58b9..1e505df 100644 --- a/assets/controllers/nostr/nostr_publish_controller.js +++ b/assets/controllers/nostr/nostr_publish_controller.js @@ -509,38 +509,45 @@ export default class extends Controller { } showStatus(message) { - if (this.hasStatusTarget) { + // Use toast system if available, otherwise fallback to status target + if (typeof window.showToast === 'function') { + window.showToast(message, 'info', 3000); + } else if (this.hasStatusTarget) { this.statusTarget.innerHTML = `
${message}
`; + // Clear status after 3 seconds + setTimeout(() => { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = ''; + } + }, 3000); } - // Clear status after 3 seconds - setTimeout(() => { - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = ''; - } - }, 3000); } showSuccess(message) { - if (this.hasStatusTarget) { + if (typeof window.showToast === 'function') { + window.showToast(message, 'success', 3000); + } else if (this.hasStatusTarget) { this.statusTarget.innerHTML = `
${message}
`; + // Clear status after 3 seconds + setTimeout(() => { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = ''; + } + }, 3000); } - // Clear status after 3 seconds - setTimeout(() => { - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = ''; - } - }, 3000); } showError(message) { - if (this.hasStatusTarget) { + if (typeof window.showToast === 'function') { + window.showToast(message, 'danger', 10000); + } else if (this.hasStatusTarget) { this.statusTarget.innerHTML = `
${message}
`; + // Clear status after 10 seconds + setTimeout(() => { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = ''; + } + }, 10000); } - // Clear status after 10 seconds - setTimeout(() => { - if (this.hasStatusTarget) { - this.statusTarget.innerHTML = ''; - } - }, 10000); } } diff --git a/assets/controllers/nostr/nostr_single_sign_controller.js b/assets/controllers/nostr/nostr_single_sign_controller.js index 00a1b11..760afe5 100644 --- a/assets/controllers/nostr/nostr_single_sign_controller.js +++ b/assets/controllers/nostr/nostr_single_sign_controller.js @@ -189,17 +189,24 @@ export default class extends Controller { } showStatus(message) { - if (this.hasStatusTarget) { + // Use toast system if available, otherwise fallback to status target + if (typeof window.showToast === 'function') { + window.showToast(message, 'info'); + } else if (this.hasStatusTarget) { this.statusTarget.innerHTML = `
${message}
`; } } showSuccess(message) { - if (this.hasStatusTarget) { + if (typeof window.showToast === 'function') { + window.showToast(message, 'success'); + } else if (this.hasStatusTarget) { this.statusTarget.innerHTML = `
${message}
`; } } showError(message) { - if (this.hasStatusTarget) { + if (typeof window.showToast === 'function') { + window.showToast(message, 'danger'); + } else if (this.hasStatusTarget) { this.statusTarget.innerHTML = `
${message}
`; } } diff --git a/assets/controllers/utility/toast_controller.js b/assets/controllers/utility/toast_controller.js new file mode 100644 index 0000000..8f58e04 --- /dev/null +++ b/assets/controllers/utility/toast_controller.js @@ -0,0 +1,162 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Central toast notification controller + * + * Usage from other controllers: + * + * // Get the toast controller instance + * const toastController = this.application.getControllerForElementAndIdentifier( + * document.querySelector('[data-controller~="utility--toast"]'), + * 'utility--toast' + * ); + * + * // Show a toast + * toastController.show('Success!', 'success'); + * toastController.show('Error occurred', 'danger'); + * toastController.show('Processing...', 'info'); + * toastController.show('Warning!', 'warning'); + * + * // Or use the global helper (recommended) + * window.showToast('Success!', 'success'); + */ +export default class extends Controller { + static targets = ['container']; + + connect() { + console.log('Toast controller connected'); + this.queue = []; + this.currentToast = null; + this.isProcessing = false; + + // Expose globally for easy access from any controller + window.showToast = (message, type = 'info', duration = 4000) => { + this.show(message, type, duration); + }; + } + + disconnect() { + // Clean up global reference + if (window.showToast) { + delete window.showToast; + } + } + + /** + * Show a toast notification + * @param {string} message - The message to display + * @param {string} type - Type of toast: 'success', 'danger', 'warning', 'info' + * @param {number} duration - How long to show the toast in milliseconds (default: 4000) + */ + show(message, type = 'info', duration = 4000) { + this.queue.push({ message, type, duration }); + this.processQueue(); + } + + /** + * Process the toast queue one at a time + */ + processQueue() { + // If already showing a toast, wait + if (this.isProcessing) { + return; + } + + // If queue is empty, nothing to do + if (this.queue.length === 0) { + return; + } + + this.isProcessing = true; + const { message, type, duration } = this.queue.shift(); + + // Create toast element + const toast = this.createToastElement(message, type); + this.containerTarget.appendChild(toast); + this.currentToast = toast; + + // Trigger animation after a small delay (for CSS transition) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + toast.classList.add('toast--show'); + }); + }); + + // Auto-dismiss after duration + setTimeout(() => { + this.dismissToast(toast); + }, duration); + } + + /** + * Create a toast DOM element + * @param {string} message + * @param {string} type + * @returns {HTMLElement} + */ + createToastElement(message, type) { + const toast = document.createElement('div'); + toast.className = `toast toast--${type}`; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'polite'); + toast.setAttribute('aria-atomic', 'true'); + + const content = document.createElement('div'); + content.className = 'toast__content'; + content.textContent = message; + + const closeButton = document.createElement('button'); + closeButton.className = 'toast__close'; + closeButton.setAttribute('type', 'button'); + closeButton.setAttribute('aria-label', 'Close'); + closeButton.innerHTML = '×'; + closeButton.addEventListener('click', () => this.dismissToast(toast)); + + toast.appendChild(content); + toast.appendChild(closeButton); + + return toast; + } + + /** + * Dismiss a toast with animation + * @param {HTMLElement} toast + */ + dismissToast(toast) { + if (!toast || !toast.parentNode) { + this.isProcessing = false; + this.processQueue(); + return; + } + + // Start fade out animation + toast.classList.remove('toast--show'); + toast.classList.add('toast--hide'); + + // Remove from DOM after animation completes + setTimeout(() => { + if (toast.parentNode) { + toast.remove(); + } + this.currentToast = null; + this.isProcessing = false; + + // Process next toast in queue + this.processQueue(); + }, 300); // Match CSS transition duration + } + + /** + * Clear all toasts immediately + */ + clearAll() { + this.queue = []; + const toasts = this.containerTarget.querySelectorAll('.toast'); + toasts.forEach(toast => { + toast.remove(); + }); + this.currentToast = null; + this.isProcessing = false; + } +} + diff --git a/assets/styles/toast.css b/assets/styles/toast.css new file mode 100644 index 0000000..fc49c9f --- /dev/null +++ b/assets/styles/toast.css @@ -0,0 +1,116 @@ +/* Toast Notification Styles */ + +.toast-container { + position: fixed; + top: 80px; /* Below the header */ + right: 20px; + z-index: 9999; + pointer-events: none; + display: flex; + flex-direction: column; + gap: 10px; + max-width: 400px; +} + +.toast { + pointer-events: auto; + background: var(--color-bg-light); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + padding: 12px 16px; + min-height: 48px; + border: 1px solid; + border-left: 4px solid; + opacity: 0; + transform: translateX(400px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.toast--show { + opacity: 1; + transform: translateX(0); +} + +.toast--hide { + opacity: 0; + transform: translateX(400px); +} + +/* Toast types - using theme colors */ +.toast--success { + border-color: var(--color-accent-600); + background-color: var(--color-bg-light); +} + +.toast--danger, +.toast--error { + border-color: #dc3545; + background-color: var(--color-bg-light); +} + +.toast--warning { + border-color: var(--color-accent-strong); + background-color: var(--color-bg-light); +} + +.toast--info { + border-color: var(--color-teal-500); + background-color: var(--color-bg-light); +} + +.toast__content { + flex: 1; + font-size: 14px; + line-height: 1.4; + color: var(--color-text); +} + +.toast__close { + background: none; + border: none; + font-size: 24px; + line-height: 1; + color: currentColor; + opacity: 0.5; + cursor: pointer; + padding: 0; + margin-left: 12px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; +} + +.toast__close:hover { + opacity: 0.8; +} + +.toast__close:focus { + outline: 2px solid currentColor; + outline-offset: 2px; + opacity: 1; +} + + +/* Mobile responsive */ +@media (max-width: 768px) { + .toast-container { + top: 60px; + right: 10px; + left: 10px; + max-width: none; + } + + .toast { + width: 100%; + } +} + +/* Animation for stacking */ +.toast:not(:last-child) { + margin-bottom: 0; +} + diff --git a/templates/components/Toast.html.twig b/templates/components/Toast.html.twig new file mode 100644 index 0000000..2db8ecb --- /dev/null +++ b/templates/components/Toast.html.twig @@ -0,0 +1,18 @@ +{# + Toast Notification Component + + Include this once in your layout to enable toast notifications throughout your app. + + Usage: + {% include 'components/Toast.html.twig' %} + + Then from JavaScript: + window.showToast('Success!', 'success'); + window.showToast('Error occurred', 'danger'); + window.showToast('Processing...', 'info'); + window.showToast('Warning!', 'warning'); +#} +
+ {# Toast messages will be dynamically inserted here #} +
+ diff --git a/templates/editor/layout.html.twig b/templates/editor/layout.html.twig index a80c3bf..f47b2ae 100644 --- a/templates/editor/layout.html.twig +++ b/templates/editor/layout.html.twig @@ -42,6 +42,9 @@ {% endblock %} {% block layout %} +{# Toast notification system #} +{% include 'components/Toast.html.twig' %} +