Browse Source

Toast service

imwald
Nuša Pukšič 4 days ago
parent
commit
a0c77d6049
  1. 3
      assets/app.js
  2. 49
      assets/controllers/nostr/nostr_publish_controller.js
  3. 13
      assets/controllers/nostr/nostr_single_sign_controller.js
  4. 162
      assets/controllers/utility/toast_controller.js
  5. 116
      assets/styles/toast.css
  6. 18
      templates/components/Toast.html.twig
  7. 3
      templates/editor/layout.html.twig

3
assets/app.js

@ -38,6 +38,9 @@ import './styles/03-components/search.css'; @@ -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';

49
assets/controllers/nostr/nostr_publish_controller.js

@ -509,38 +509,45 @@ export default class extends Controller { @@ -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 = `<div class="alert alert-info">${message}</div>`;
// 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 = `<div class="alert alert-success">${message}</div>`;
// 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 = `<div class="alert alert-danger">${message}</div>`;
// 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);
}
}

13
assets/controllers/nostr/nostr_single_sign_controller.js

@ -189,17 +189,24 @@ export default class extends Controller { @@ -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 = `<div class="alert alert-info">${message}</div>`;
}
}
showSuccess(message) {
if (this.hasStatusTarget) {
if (typeof window.showToast === 'function') {
window.showToast(message, 'success');
} else if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`;
}
}
showError(message) {
if (this.hasStatusTarget) {
if (typeof window.showToast === 'function') {
window.showToast(message, 'danger');
} else if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`;
}
}

162
assets/controllers/utility/toast_controller.js

@ -0,0 +1,162 @@ @@ -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 = '&times;';
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;
}
}

116
assets/styles/toast.css

@ -0,0 +1,116 @@ @@ -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;
}

18
templates/components/Toast.html.twig

@ -0,0 +1,18 @@ @@ -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');
#}
<div {{ stimulus_controller('utility--toast') }} class="toast-container" data-utility--toast-target="container">
{# Toast messages will be dynamically inserted here #}
</div>

3
templates/editor/layout.html.twig

@ -42,6 +42,9 @@ @@ -42,6 +42,9 @@
{% endblock %}
{% block layout %}
{# Toast notification system #}
{% include 'components/Toast.html.twig' %}
<main class="editor-layout" data-controller="editor--layout nostr--nostr-single-sign" data-article-id="{{ article.id|default('') }}" data-nostr--nostr-single-sign-publish-url-value="{{ path('api-article-publish') }}">
<div class="editor-main">
<div data-nostr--nostr-single-sign-target="status"

Loading…
Cancel
Save