Files
rspade_system/app/RSpade/Lib/Flash/Flash_Alert.js
root 9ebcc359ae Fix code quality violations and enhance ROUTE-EXISTS-01 rule
Implement JQHTML function cache ID system and fix bundle compilation
Implement underscore prefix for system tables
Fix JS syntax linter to support decorators and grant exception to Task system
SPA: Update planning docs and wishlists with remaining features
SPA: Document Navigation API abandonment and future enhancements
Implement SPA browser integration with History API (Phase 1)
Convert contacts view page to SPA action
Convert clients pages to SPA actions and document conversion procedure
SPA: Merge GET parameters and update documentation
Implement SPA route URL generation in JavaScript and PHP
Implement SPA bootstrap controller architecture
Add SPA routing manual page (rsx:man spa)
Add SPA routing documentation to CLAUDE.md
Phase 4 Complete: Client-side SPA routing implementation
Update get_routes() consumers for unified route structure
Complete SPA Phase 3: PHP-side route type detection and is_spa flag
Restore unified routes structure and Manifest_Query class
Refactor route indexing and add SPA infrastructure
Phase 3 Complete: SPA route registration in manifest
Implement SPA Phase 2: Extract router code and test decorators
Rename Jqhtml_Component to Component and complete SPA foundation setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 17:48:15 +00:00

590 lines
24 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Flash_Alert - Temporary alert notification system with queue persistence
*
* Displays dismissable alert messages that auto-fade after a timeout.
* Messages are queued to prevent overwhelming the user with simultaneous alerts.
* Queue state persists across page navigations using sessionStorage (per-tab).
*
* Usage:
* Flash_Alert.success('Changes saved!')
* Flash_Alert.error('Something went wrong')
* Flash_Alert.info('FYI: New feature available')
* Flash_Alert.warning('Are you sure about that?')
*
* Features:
* - Queue system prevents alert spam (2.5s minimum between alerts)
* - Auto-dismiss after timeout (success: 4s, others: 6s)
* - Click to dismiss immediately
* - Smooth fade in/out animations
* - Bootstrap alert styling with icons
* - Persistent queue across page navigations (sessionStorage, tab-specific)
* - Stale message filtering (entire queue discarded if > 20 seconds old)
* - SPA-like experience: messages maintain timing state across navigation
*
* Queue Architecture:
* - Two-queue system: working queue (_queue) and persistence queue (_persistence_queue)
* - Working queue: Messages removed when displayed (controls display timing/spacing)
* - Persistence queue: Messages removed when fadeout starts (saved to sessionStorage)
* - Prevents duplicate displays while maintaining cross-navigation state
*
* Persistence System:
* - Queue state saved to sessionStorage when messages queued or state changes
* - State restored on page load via Server_Side_Flash._on_framework_core_init()
* - Entire queue discarded if last_updated timestamp > 20 seconds old
* - Individual messages filtered: discard if past their fadeout_start_time
* - Per-tab isolation (sessionStorage doesn't share across browser tabs)
* - Handles Ajax response + redirect scenario (message survives navigation)
*
* Message Lifecycle & Timing:
* - Queued → Display (fade-in 400ms) → Visible (4s success, 6s others) → Fadeout (1s) → Removed
* - fade_in_complete flag: Set after fade-in animation completes
* - fadeout_start_time: Calculated timestamp when fadeout should begin
* - Both saved to sessionStorage to maintain timing consistency across navigation
*
* Navigation Behavior:
* - beforeunload event: Hides alerts still fading in, keeps fully visible alerts (with scheduled fadeout)
* - On restore: Messages with fade_in_complete=true show immediately (no fade-in, no queue delay)
* - On restore: Messages with fade_in_complete=false added to queue (normal 2.5s spacing)
* - Restored messages honor original fadeout_start_time (not restarted)
* - Creates seamless SPA-like experience across full page navigations
*
* Common Scenarios:
* 1. Ajax + Redirect: Flash message queued during Ajax, page redirects immediately
* → Message saved to sessionStorage → Restored and displayed on new page
* 2. Mid-animation navigation: Alert fading in when user navigates away
* → Alert hidden during navigation → Restored on new page, continues fade-in
* 3. Visible alert navigation: Alert fully visible when user navigates
* → Alert stays visible → Appears immediately on new page, honors original fadeout timing
*/
class Flash_Alert {
// Queue and state tracking
static _queue = []; // Working queue - messages removed when displayed
static _persistence_queue = []; // Persistence queue - messages removed when fadeout starts
static _is_in_progress = false;
static _last_alert_time = 0;
static _container = null;
/**
* Initialize the flash alert container
* @private
*/
static _init() {
if (this._container) return;
// Create floating alert container
this._container = $('<div id="floating-alert-container" class="floating-alert-container"></div>');
$('body').append(this._container);
// Register page navigation handler (only once)
window.addEventListener('beforeunload', () => {
console.log('[Flash_Alert] Page navigation detected - cleaning up');
this._cleanup_for_navigation();
});
}
/**
* Cleanup alerts on page navigation
* Hides alerts still fading in, leaves fully faded-in alerts visible with scheduled fadeout
* @private
*/
static _cleanup_for_navigation() {
console.log('[Flash_Alert] Cleaning up for navigation:', {
working_queue_before: this._queue.length,
persistence_queue: this._persistence_queue.length,
});
if (this._container) {
const now = Date.now();
// Process each visible alert
this._container.find('.alert-wrapper').each((index, wrapper) => {
const $wrapper = $(wrapper);
const message_text = $wrapper.find('.alert').text().trim().replace(/×$/, '').trim();
// Find this message in persistence queue to check fade_in_complete
const msg = this._persistence_queue.find(m => m.message === message_text);
if (msg && msg.fade_in_complete) { // @JS-DEFENSIVE-01-EXCEPTION - Array.find() returns undefined when no match is found
// Alert has fully faded in - leave it visible
console.log('[Flash_Alert] Leaving fully faded-in alert visible:', message_text);
// Schedule fadeout based on fadeout_start_time
if (msg.fadeout_start_time) {
const time_until_fadeout = Math.max(0, msg.fadeout_start_time - now);
setTimeout(() => {
console.log('[Flash_Alert] Navigation fadeout starting for:', message_text);
// Fade to transparent, then slide up
$wrapper.animate({ opacity: 0 }, 1000, () => {
$wrapper.slideUp(250, () => {
$wrapper.remove();
});
});
}, time_until_fadeout);
console.log('[Flash_Alert] Scheduled fadeout for visible alert:', {
message: message_text,
time_until_fadeout,
fadeout_start_time: msg.fadeout_start_time
});
}
} else {
// Alert still fading in - remove it
console.log('[Flash_Alert] Removing alert still fading in:', message_text);
$wrapper.remove();
}
});
}
// Clear working queue (stop processing new alerts)
this._queue = [];
// Stop processing
this._is_in_progress = false;
// DO NOT touch _persistence_queue or sessionStorage
// They will be restored on the next page load
console.log('[Flash_Alert] Cleanup complete - persistence queue preserved');
}
/**
* Save persistence queue state to sessionStorage
* @private
*/
static _save_queue_state() {
const state = {
last_updated: Date.now(),
messages: this._persistence_queue.map((item) => ({
message: item.message,
level: item.level,
timeout: item.timeout,
position: item.position,
queued_at: item.queued_at,
fade_in_complete: item.fade_in_complete || false,
fadeout_start_time: item.fadeout_start_time || null,
})),
};
console.log('[Flash_Alert] Saving persistence queue to sessionStorage:', {
message_count: state.messages.length,
last_updated: state.last_updated,
messages: state.messages,
});
Rsx_Storage.session_set('rsx_flash_queue', state);
}
/**
* Load queue state from sessionStorage
* Discards entire queue if last_updated timestamp is > 20 seconds old
* @private
*/
static _restore_queue_state() {
const state = Rsx_Storage.session_get('rsx_flash_queue');
console.log('[Flash_Alert] Attempting to restore queue state from sessionStorage:', {
has_stored_data: !!state,
stored_data: state,
});
if (!state) {
console.log('[Flash_Alert] No stored queue data found');
return;
}
try {
const now = Date.now();
const MAX_AGE = 20000; // 20 seconds in milliseconds
console.log('[Flash_Alert] Parsed stored state:', {
message_count: state.messages.length,
last_updated: state.last_updated,
messages: state.messages,
current_time: now,
});
// Check if the stored queue is > 20 seconds old
// If so, throw out the entire queue
const queue_age = now - state.last_updated;
if (queue_age > MAX_AGE) {
console.log('[Flash_Alert] Stored queue is too old, discarding entire queue:', {
queue_age_ms: queue_age,
max_age_ms: MAX_AGE,
last_updated: state.last_updated,
});
Rsx_Storage.session_remove('rsx_flash_queue');
return;
}
// Filter messages: remove those past their fadeout time
const valid_messages = state.messages.filter((msg) => {
// If fadeout was scheduled and already passed, discard message
if (msg.fadeout_start_time && now >= msg.fadeout_start_time) {
console.log('[Flash_Alert] Discarding message past fadeout time:', {
message: msg.message,
fadeout_start_time: msg.fadeout_start_time,
current_time: now,
time_past_fadeout: now - msg.fadeout_start_time,
});
return false;
}
return true;
});
console.log('[Flash_Alert] Valid messages to restore:', {
count: valid_messages.length,
messages: valid_messages,
});
// Separate messages into two groups: fade-in complete vs not
const messages_to_show_immediately = [];
const messages_to_queue = [];
valid_messages.forEach((msg) => {
const message_data = {
message: msg.message,
level: msg.level,
timeout: msg.timeout,
position: msg.position,
queued_at: msg.queued_at,
fade_in_complete: msg.fade_in_complete || false,
fadeout_start_time: msg.fadeout_start_time || null,
};
if (msg.fade_in_complete) {
// Already completed fade-in - show immediately
messages_to_show_immediately.push(message_data);
} else {
// Still needs fade-in - add to queue
messages_to_queue.push(message_data);
}
// Add all to persistence queue
this._persistence_queue.push(message_data);
});
console.log('[Flash_Alert] Messages after restoration:', {
to_show_immediately: messages_to_show_immediately.length,
to_queue: messages_to_queue.length,
persistence_queue_length: this._persistence_queue.length,
});
// Re-save persistence queue to sessionStorage
this._save_queue_state();
// Ensure container is initialized before displaying
this._init();
// Display fade-in complete messages immediately (no delay, no fade-in)
// These are displayed outside the normal queue, so don't affect _is_in_progress
if (messages_to_show_immediately.length > 0) {
console.log('[Flash_Alert] Displaying fade-in complete messages immediately');
messages_to_show_immediately.forEach((msg) => {
this._display_alert(msg, true); // true = immediate display, don't block queue
});
}
// Add remaining messages to working queue for normal processing
messages_to_queue.forEach((msg) => {
this._queue.push(msg);
});
// Start queue processing for messages that still need fade-in
if (this._queue.length > 0) {
console.log('[Flash_Alert] Starting queue processing for messages needing fade-in');
if (!this._is_in_progress) {
this._process_queue();
}
}
} catch (e) {
console.error('[Flash_Alert] Failed to restore flash queue state:', e);
sessionStorage.removeItem('rsx_flash_queue');
}
}
/**
* Remove a message from persistence queue when fadeout starts
* @private
*/
static _remove_from_persistence_queue(message, level) {
const original_count = this._persistence_queue.length;
this._persistence_queue = this._persistence_queue.filter((msg) => {
return !(msg.level === level && msg.message === message);
});
console.log('[Flash_Alert] Removing message from persistence queue:', {
message: message,
level: level,
original_count: original_count,
remaining_count: this._persistence_queue.length,
});
// Save updated persistence queue to sessionStorage
this._save_queue_state();
}
/**
* Mark fade-in complete for a message in persistence queue
* @private
*/
static _mark_fade_in_complete(message, level) {
const msg = this._persistence_queue.find((m) => m.level === level && m.message === message);
if (msg) {
msg.fade_in_complete = true;
console.log('[Flash_Alert] Marked fade-in complete:', { message, level });
this._save_queue_state();
}
}
/**
* Set fadeout start time for a message in persistence queue
* @private
*/
static _set_fadeout_start_time(message, level, fadeout_start_time) {
const msg = this._persistence_queue.find((m) => m.level === level && m.message === message);
if (msg) {
msg.fadeout_start_time = fadeout_start_time;
console.log('[Flash_Alert] Set fadeout start time:', { message, level, fadeout_start_time });
this._save_queue_state();
}
}
/**
* Show a success alert
* @param {string} message - The message to display
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 4000ms)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
*/
static success(message, timeout = null, position = 'top') {
this._show(message, 'success', timeout, position);
}
/**
* Show an error alert
* @param {string} message - The message to display
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 6000ms)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
*/
static error(message, timeout = null, position = 'top') {
this._show(message, 'danger', timeout, position);
}
/**
* Show an info alert
* @param {string} message - The message to display
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 6000ms)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
*/
static info(message, timeout = null, position = 'top') {
this._show(message, 'info', timeout, position);
}
/**
* Show a warning alert
* @param {string} message - The message to display
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 6000ms)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
*/
static warning(message, timeout = null, position = 'top') {
this._show(message, 'warning', timeout, position);
}
/**
* Show an alert with custom level
* @param {string} message - The message to display
* @param {string} level - Alert level: 'success', 'danger', 'info', 'warning'
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = use default)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
* @private
*/
static _show(message, level = 'info', timeout = null, position = 'top') {
this._init();
// Add to BOTH queues with timestamp
const message_data = {
message,
level,
timeout,
position,
queued_at: Date.now(),
};
this._queue.push(message_data);
this._persistence_queue.push(message_data);
// Save persistence queue to sessionStorage
this._save_queue_state();
// Process queue if not already processing
if (!this._is_in_progress) {
this._process_queue();
}
}
/**
* Process the next alert in the queue
* @private
*/
static _process_queue() {
const now = Date.now();
console.log('[Flash_Alert] Processing queue:', {
working_queue_length: this._queue.length,
persistence_queue_length: this._persistence_queue.length,
is_in_progress: this._is_in_progress,
time_since_last: now - this._last_alert_time,
});
// Display next alert if enough time has passed (2.5s minimum between alerts)
if (now - this._last_alert_time >= 2500 && this._queue.length > 0) {
// Remove from working queue (shift) but stays in persistence queue until fadeout
const alert_data = this._queue.shift();
console.log('[Flash_Alert] Displaying alert from queue:', alert_data);
console.log('[Flash_Alert] Working queue after shift:', this._queue.length);
this._display_alert(alert_data);
this._last_alert_time = now;
}
// Schedule next queue check if more alerts pending
if (this._queue.length > 0) {
console.log('[Flash_Alert] Scheduling next queue check in 2.5s');
setTimeout(() => this._process_queue(), 2500);
} else {
console.log('[Flash_Alert] Working queue empty, stopping processing');
this._is_in_progress = false;
}
}
/**
* Display a single alert
* @param {Object} alert_data - Alert configuration
* @param {boolean} is_immediate - If true, don't set _is_in_progress (for restored alerts displayed outside queue)
* @private
*/
static _display_alert({ message, level, timeout, position = 'top', fade_in_complete = false, fadeout_start_time = null }, is_immediate = false) {
// Only set in_progress if this is a queued display (not an immediate restored alert)
if (!is_immediate) {
this._is_in_progress = true;
}
console.log('[Flash_Alert] Displaying alert:', {
message,
level,
fade_in_complete,
fadeout_start_time,
current_time: Date.now(),
});
// Check if an alert with the same message is already displayed
let duplicate_found = false;
this._container.find('.alert-wrapper').each(function () {
const existing_text = $(this).find('.alert').text().trim();
// Remove the close button text (×) for comparison
const existing_message = existing_text.replace(/×$/, '').trim();
if (existing_message === message) {
duplicate_found = true;
return false; // Break loop
}
});
// Skip displaying if duplicate found
if (duplicate_found) {
console.log('[Flash_Alert] Duplicate found, skipping display');
return;
}
// Create alert element
const $alert = $(`<div class="alert alert-${level} alert-dismissible fade show" role="alert">`);
// Add icon based on level
if (level === 'danger') {
$alert.append('<i class="bi bi-exclamation-circle-fill me-2"></i>');
} else if (level === 'success') {
$alert.append('<i class="bi bi-check-circle-fill me-2"></i>');
} else if (level === 'info') {
$alert.append('<i class="bi bi-info-circle-fill me-2"></i>');
} else if (level === 'warning') {
$alert.append('<i class="bi bi-exclamation-triangle-fill me-2"></i>');
}
// Add close button
const $close_button = $('<button type="button" class="btn-close" aria-label="Close"></button>');
$alert.append($close_button).append(message);
// Wrap in container for animation
const $alert_container = $('<div class="alert-wrapper">').append($alert);
// Add to floating container based on position
if (position === 'top') {
// Top position - append (newer alerts below older ones)
this._container.css('justify-content', 'flex-start').append($alert_container);
} else {
// Bottom position - prepend (newer alerts above older ones)
this._container.css('justify-content', 'flex-end').prepend($alert_container);
}
// Fade in (skip if already completed fade-in on previous page)
if (fade_in_complete) {
console.log('[Flash_Alert] Skipping fade-in, showing immediately (restored from previous page)');
$alert_container.show();
} else {
$alert_container.hide().fadeIn(400, () => {
console.log('[Flash_Alert] Fade-in complete');
// Mark fade-in complete in persistence queue
this._mark_fade_in_complete(message, level);
});
}
// Close function - fade to transparent, then slide up
const close_alert = (speed) => {
$alert_container.animate({ opacity: 0 }, speed, () => {
$alert_container.slideUp(250, () => {
$alert_container.remove();
});
});
};
// Calculate when fadeout should start
const now = Date.now();
let time_until_fadeout;
if (fadeout_start_time) {
// Honor existing fadeout schedule from restored state
time_until_fadeout = Math.max(0, fadeout_start_time - now);
console.log('[Flash_Alert] Using restored fadeout schedule:', {
fadeout_start_time,
now,
time_until_fadeout,
});
} else {
// New message - calculate fadeout time (4s for success, 6s for others)
const display_duration = level === 'success' ? 4000 : 6000;
time_until_fadeout = display_duration;
const new_fadeout_start_time = now + display_duration;
console.log('[Flash_Alert] Scheduling new fadeout:', {
display_duration,
fadeout_start_time: new_fadeout_start_time,
});
// Store fadeout start time in persistence queue
this._set_fadeout_start_time(message, level, new_fadeout_start_time);
}
// Schedule fadeout
if (time_until_fadeout >= 0) {
setTimeout(() => {
console.log('[Flash_Alert] Fadeout starting for:', message);
// Remove from persistence queue when fadeout starts
this._remove_from_persistence_queue(message, level);
close_alert(1000);
}, time_until_fadeout);
}
// Click anywhere on alert to dismiss
$alert.click(() => {
// Remove from persistence queue when manually dismissed
this._remove_from_persistence_queue(message, level);
close_alert(300);
});
}
}