/** * Shell * Provides shell state management, context, provider, placement, and singleton manager * Platform-agnostic shell system for UI layout control */ import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; import { XStack, YStack, Text, Button, Spinner } from 'tamagui'; import RecordsModel from '../../data/RecordsModel.js'; import { getIcon } from './IconMapper.jsx'; import { SidePanelShell } from './SidePanelShell.jsx'; import { networkActivityManager } from '../../platform/api.js'; // ============================================================================ // Shell Context // ============================================================================ const ShellContext = createContext(null); // ============================================================================ // Shell Manager (Singleton) // ============================================================================ /** * Shell Manager * Singleton that maintains shell state and provides global access for handlers * Accessible via Shell.Manager for global access */ class ShellManager { constructor() { this._state = { leftSideWidth: 0, rightSideWidth: 0, headerHeight: 0, footerHeight: 0 }; this._setters = { setLeftSideWidth: null, setRightSideWidth: null, setHeaderHeight: null, setFooterHeight: null }; this._listeners = new Set(); } /** * Initialize shell manager (called by ShellProvider) * @param {Object} setters - State setter functions from ShellProvider */ _init(setters) { this._setters = setters; } /** * Update internal state (called by ShellProvider) */ _updateState(state) { this._state = { ...state }; this._notifyListeners(); } /** * Notify all listeners of state changes */ _notifyListeners() { this._listeners.forEach(listener => { try { listener({ ...this._state }); } catch (error) { console.warn('[ShellManager] Listener error:', error); } }); } /** * Get current state * @returns {Object} Current shell state */ getState() { return { ...this._state }; } /** * Get left side width * @returns {number} */ getLeftSideWidth() { return this._state.leftSideWidth; } /** * Get right side width * @returns {number} */ getRightSideWidth() { return this._state.rightSideWidth; } /** * Set left side width * @param {number} width */ setLeftSideWidth(width) { if (this._setters.setLeftSideWidth) { this._setters.setLeftSideWidth(width); } else { console.warn('[ShellManager] setLeftSideWidth not initialized'); } } /** * Set right side width * @param {number} width */ setRightSideWidth(width) { if (this._setters.setRightSideWidth) { this._setters.setRightSideWidth(width); } else { console.warn('[ShellManager] setRightSideWidth not initialized'); } } /** * Toggle left side * @param {number} [targetWidth=250] - Target width when opening */ toggleLeftSide(targetWidth = 250) { const currentWidth = this._state.leftSideWidth; this.setLeftSideWidth(currentWidth === 0 ? targetWidth : 0); } /** * Toggle right side * @param {number} [targetWidth=250] - Target width when opening */ toggleRightSide(targetWidth = 250) { const currentWidth = this._state.rightSideWidth; this.setRightSideWidth(currentWidth === 0 ? targetWidth : 0); } /** * Subscribe to state changes * @param {Function} listener - Callback function * @returns {Function} Unsubscribe function */ subscribe(listener) { this._listeners.add(listener); return () => { this._listeners.delete(listener); }; } } // Create singleton instance const shellManager = new ShellManager(); // ============================================================================ // Toast Manager (Singleton) // ============================================================================ /** * Toast Manager * Singleton that maintains toast state and provides global access for handlers * Accessible via Shell.ToastManager for global access */ class ToastManager { constructor() { this._toasts = []; this._setToasts = null; this._maxToasts = 5; this._defaultDuration = 5000; this._timeouts = new Map(); // Map of toast ID to timeout ID this._pausedTimes = new Map(); // Map of toast ID to remaining time when paused } /** * Initialize toast manager (called by ShellProvider) * @param {Function} setToasts - State setter function from ShellProvider */ _init(setToasts) { this._setToasts = setToasts; } /** * Generate unique toast ID * @returns {string} */ _generateId() { return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Show a toast notification * @param {string} title - Toast title * @param {string} [message] - Toast message * @param {Object} [options] - Toast options * @param {string} [options.type='info'] - Toast type: 'info' | 'success' | 'warning' | 'error' * @param {number} [options.duration] - Auto-dismiss duration in ms (default: 5000) * @returns {string} Toast ID */ show(title, message = '', options = {}) { const { type = 'info', duration = this._defaultDuration, persistToNotifications = true } = options; const startTime = Date.now(); const toast = { id: this._generateId(), title, message, type, duration, persistToNotifications, timestamp: startTime, startTime: startTime // Store start time for pause/resume calculations }; if (!this._setToasts) { console.warn('[ToastManager] Toast setter not initialized'); return toast.id; } this._setToasts(prev => { const updated = [toast, ...prev]; // Limit max toasts return updated.slice(0, this._maxToasts); }); // Auto-dismiss after duration if (duration > 0) { const timeoutId = setTimeout(() => { if (toast.persistToNotifications) { notificationCenterManager.addFromToast(toast); } this.hide(toast.id); this._timeouts.delete(toast.id); }, duration); this._timeouts.set(toast.id, timeoutId); } return toast.id; } /** * Hide a toast by ID * @param {string} id - Toast ID */ hide(id) { if (!this._setToasts) { console.warn('[ToastManager] Toast setter not initialized'); return; } // Clear timeout if exists const timeoutId = this._timeouts.get(id); if (timeoutId) { clearTimeout(timeoutId); this._timeouts.delete(id); } this._pausedTimes.delete(id); this._setToasts(prev => prev.filter(toast => toast.id !== id)); } /** * Pause auto-dismiss timeout for a toast (e.g., when hovered) * @param {string} id - Toast ID */ pauseTimeout(id) { const timeoutId = this._timeouts.get(id); if (!timeoutId) return; // No active timeout if (!this._setToasts) return; // Get the toast to calculate remaining time let toast = null; this._setToasts(prev => { toast = prev.find(t => t.id === id); return prev; }); if (!toast || !toast.duration || !toast.startTime) return; // Calculate elapsed time and remaining time const elapsed = Date.now() - toast.startTime; const remaining = toast.duration - elapsed; if (remaining <= 0) { // Time already expired, hide immediately this.hide(id); return; } // Clear the current timeout clearTimeout(timeoutId); this._timeouts.delete(id); // Store remaining time this._pausedTimes.set(id, { remaining }); } /** * Resume auto-dismiss timeout for a toast (e.g., when hover ends) * @param {string} id - Toast ID */ resumeTimeout(id) { const pausedData = this._pausedTimes.get(id); if (!pausedData) return; // Wasn't paused if (!this._setToasts) return; // Get the toast to verify it still exists let toast = null; this._setToasts(prev => { toast = prev.find(t => t.id === id); return prev; }); if (!toast || !toast.duration) return; // Use the remaining time from when it was paused const remaining = pausedData.remaining; if (remaining <= 0) { // Time already expired, hide immediately this.hide(id); return; } // Set new timeout with remaining time const timeoutId = setTimeout(() => { if (toast.persistToNotifications) { notificationCenterManager.addFromToast(toast); } this.hide(id); this._timeouts.delete(id); }, remaining); this._timeouts.set(id, timeoutId); this._pausedTimes.delete(id); } /** * Clear all toasts */ clear() { if (!this._setToasts) { console.warn('[ToastManager] Toast setter not initialized'); return; } // Clear all timeouts this._timeouts.forEach(timeoutId => clearTimeout(timeoutId)); this._timeouts.clear(); this._pausedTimes.clear(); this._setToasts([]); } /** * Get all active toasts * @returns {Array} */ getToasts() { return [...this._toasts]; } getNotificationModel() { return notificationCenterManager.getModel(); } setNotificationModel(model) { notificationCenterManager.setModel(model); } openNotifications() { notificationCenterManager.open(); } closeNotifications() { notificationCenterManager.close(); } toggleNotifications() { notificationCenterManager.toggle(); } } // Create singleton instance const toastManager = new ToastManager(); function createDefaultNotificationModel() { const now = Date.now(); return new RecordsModel({ rows: [ { id: 'welcome-notification', title: 'Welcome', message: 'Notifications that expire from toast popups will appear here until dismissed.', type: 'info', timestamp: now, created_at: now, source: 'system', status: 'pending', is_read: false, resource_path: '/notifications', action_label: null, action_target: null, action_kind: null, meta: {} } ], idField: 'id', searchFields: ['title', 'message', 'type'], summaryDefinitions: [ { id: 'total', label: 'Pending', type: 'count' } ] }); } class NotificationCenterManager { constructor() { this._open = false; this._setOpen = null; this._model = createDefaultNotificationModel(); this._listeners = new Set(); this._unbindModelListener = null; this._bindModelListener(); } _init(setOpen) { this._setOpen = setOpen; } _bindModelListener() { if (this._unbindModelListener) { this._unbindModelListener(); this._unbindModelListener = null; } if (this._model?.subscribe) { this._unbindModelListener = this._model.subscribe(() => { this._notifyListeners(); }); } } _setOpenState(open) { this._open = Boolean(open); this._setOpen?.(this._open); this._notifyListeners(); } _notifyListeners() { this._listeners.forEach((listener) => { try { listener({ open: this._open, model: this._model }); } catch (error) { console.warn('[NotificationCenterManager] Listener failed:', error); } }); } subscribe(listener) { this._listeners.add(listener); return () => { this._listeners.delete(listener); }; } isOpen() { return this._open; } open() { this._setOpenState(true); } close() { this._setOpenState(false); } toggle() { this._setOpenState(!this._open); } getModel() { return this._model; } setModel(model) { if (!model) { return; } this._model = model; this._bindModelListener(); this._notifyListeners(); } async addFromToast(toast) { if (!toast || !this._model?.createRecord) { return; } const existing = this._model.getRecord ? await this._model.getRecord(toast.id) : null; if (existing && this._model.updateRecord) { await this._model.updateRecord(toast.id, { title: toast.title, message: toast.message, type: toast.type, timestamp: toast.timestamp, created_at: toast.timestamp, source: 'toast', status: 'pending', is_read: false, resource_path: '/notifications', action_label: toast.action_label ?? null, action_target: toast.action_target ?? null, action_kind: toast.action_kind ?? null, meta: toast.meta ?? {} }); return; } await this._model.createRecord({ id: toast.id, title: toast.title, message: toast.message, type: toast.type, timestamp: toast.timestamp, created_at: toast.timestamp, source: 'toast', status: 'pending', is_read: false, resource_path: '/notifications', action_label: toast.action_label ?? null, action_target: toast.action_target ?? null, action_kind: toast.action_kind ?? null, meta: toast.meta ?? {} }); } async dismiss(id) { if (!this._model?.deleteRecord) { return; } await this._model.deleteRecord(id); } async clear() { const model = this._model; if (!model?.queryRecords || !model?.deleteRecord) { return; } const result = await model.queryRecords({ offset: 0, page_size: 500, sort_by: [{ field: 'timestamp', direction: 'desc' }] }); await Promise.all((result.rows || []).map((row) => model.deleteRecord(row.id))); } } const notificationCenterManager = new NotificationCenterManager(); function getNotificationTypeStyle(type = 'info') { return { info: { backgroundColor: '$blue3', borderColor: '$blue8', icon: 'info' }, success: { backgroundColor: '$green3', borderColor: '$green8', icon: 'success' }, warning: { backgroundColor: '$yellow3', borderColor: '$yellow8', icon: 'warning' }, error: { backgroundColor: '$red3', borderColor: '$red8', icon: 'error' } }[type] || { backgroundColor: '$blue3', borderColor: '$blue8', icon: 'info' }; } // ============================================================================ // Shell Provider // ============================================================================ /** * Shell Provider Component * Provides shell state and control functions to child components * * @param {Object} props * @param {React.ReactNode} props.children * @param {number} [props.initialLeftWidth=0] * @param {number} [props.initialRightWidth=0] * @param {number} [props.initialHeaderHeight=0] * @param {number} [props.initialFooterHeight=0] */ export function ShellProvider({ children, initialLeftWidth = 0, initialRightWidth = 0, initialHeaderHeight = 0, initialFooterHeight = 0 }) { const [leftSideWidth, setLeftSideWidth] = useState(initialLeftWidth); const [rightSideWidth, setRightSideWidth] = useState(initialRightWidth); const [headerHeight, setHeaderHeight] = useState(initialHeaderHeight); const [footerHeight, setFooterHeight] = useState(initialFooterHeight); // Toast state const [toasts, setToasts] = useState([]); const [notificationsOpen, setNotificationsOpen] = useState(notificationCenterManager.isOpen()); const [networkActivity, setNetworkActivity] = useState(networkActivityManager.getState()); // Initialize shell manager with setters useEffect(() => { shellManager._init({ setLeftSideWidth, setRightSideWidth, setHeaderHeight, setFooterHeight }); }, []); // Initialize toast manager with setter useEffect(() => { toastManager._init(setToasts); }, []); useEffect(() => { notificationCenterManager._init(setNotificationsOpen); }, []); useEffect(() => networkActivityManager.subscribe(setNetworkActivity), []); // Update shell manager state when it changes useEffect(() => { shellManager._updateState({ leftSideWidth, rightSideWidth, headerHeight, footerHeight }); }, [leftSideWidth, rightSideWidth, headerHeight, footerHeight]); // Toggle functions for convenience const toggleLeftSide = useCallback((targetWidth = 250) => { setLeftSideWidth(prev => prev === 0 ? targetWidth : 0); }, []); const toggleRightSide = useCallback((targetWidth = 250) => { setRightSideWidth(prev => prev === 0 ? targetWidth : 0); }, []); // Toast functions const showToast = useCallback((title, message = '', options = {}) => { return toastManager.show(title, message, options); }, []); const hideToast = useCallback((id) => { toastManager.hide(id); }, []); const clearToasts = useCallback(() => { toastManager.clear(); }, []); const pauseToast = useCallback((id) => { toastManager.pauseTimeout(id); }, []); const resumeToast = useCallback((id) => { toastManager.resumeTimeout(id); }, []); const value = { leftSideWidth, rightSideWidth, headerHeight, footerHeight, setLeftSideWidth, setRightSideWidth, setHeaderHeight, setFooterHeight, toggleLeftSide, toggleRightSide, toast: { show: showToast, hide: hideToast, clear: clearToasts, pause: pauseToast, resume: resumeToast, toasts } }; return ( {children} ); } // ============================================================================ // Shell Hooks // ============================================================================ /** * Hook to access shell context * @returns {Object} Shell state and control functions */ export function useShell() { const context = useContext(ShellContext); if (!context) { throw new Error('useShell must be used within a ShellProvider'); } return context; } // ============================================================================ // Toast Components // ============================================================================ /** * Toast Component * Individual toast notification item */ function Toast({ toast, onClose, onPause, onResume }) { const [isHovered, setIsHovered] = useState(false); const [isExiting, setIsExiting] = useState(false); // Pause/resume timeout on hover useEffect(() => { if (isHovered) { onPause?.(toast.id); } else { onResume?.(toast.id); } }, [isHovered, toast.id, onPause, onResume]); const handleClose = () => { setIsExiting(true); setTimeout(() => { onClose(toast.id); }, 300); // Match animation duration }; // Type-specific styling const style = getNotificationTypeStyle(toast.type); const Icon = getIcon(style.icon); return ( setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} animation="quick" opacity={isExiting ? 0 : 1} style={{ transition: 'opacity 0.3s ease, transform 0.3s ease', transform: isExiting ? 'translateX(400px)' : 'translateX(0)' }} role="status" aria-live="polite" > {Icon ? ( ) : null} {toast.title && ( {toast.title} )} {toast.message && ( {toast.message} )} ); } function NotificationRecord({ record, onDismiss }) { const style = getNotificationTypeStyle(record.type); const Icon = getIcon(style.icon); const CloseIcon = getIcon('close'); return ( {Icon ? ( ) : null} {record.title ? ( {record.title} ) : null} {record.message ? ( {record.message} ) : null} {record.timestamp ? ( {new Date(record.timestamp).toLocaleString()} ) : null} {record.action_label ? ( {record.action_label} ) : null}