/**
* 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}
);
}
function NotificationCenterPanel({ open = false }) {
const [model, setModel] = useState(notificationCenterManager.getModel());
const [dataVersion, setDataVersion] = useState(0);
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
return notificationCenterManager.subscribe(({ model: nextModel }) => {
setModel(nextModel);
setDataVersion((value) => value + 1);
});
}, []);
useEffect(() => {
if (!model?.subscribe) {
return undefined;
}
return model.subscribe(() => {
setDataVersion((value) => value + 1);
});
}, [model]);
useEffect(() => {
let cancelled = false;
async function loadNotifications() {
if (!open || !model?.queryRecords) {
if (!cancelled) {
setRecords([]);
}
return;
}
setLoading(true);
setError('');
try {
const result = await model.queryRecords({
offset: 0,
page_size: 100,
sort_by: [{ field: 'timestamp', direction: 'desc' }]
});
if (!cancelled) {
setRecords(result?.rows || []);
}
} catch (loadError) {
if (!cancelled) {
setError(String(loadError?.message || loadError));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadNotifications();
return () => {
cancelled = true;
};
}, [open, model, dataVersion]);
return (
notificationCenterManager.close()}
title="Notifications"
width={360}
toolbar={records.length > 0 ? [
{
id: 'clear-notifications',
label: 'Clear All',
chromeless: true,
onPress: () => notificationCenterManager.clear()
}
] : []}
>
{error ? (
{error}
) : null}
{loading ? (
Loading notifications...
) : null}
{!loading && records.length === 0 ? (
No pending notifications
Expired toast messages will appear here until dismissed.
) : null}
{records.map((record) => (
notificationCenterManager.dismiss(id)}
/>
))}
);
}
/**
* ToastViewport Component
* Container for toast notifications (fixed bottom-right)
*/
export function ToastViewport() {
const { toast } = useShell();
if (!toast || !toast.toasts || toast.toasts.length === 0) {
return null;
}
return (
{toast.toasts.map((toastItem) => (
))}
);
}
function NetworkActivityOverlay({ visible = false }) {
if (!visible) {
return null;
}
return (
Working...
);
}
// ============================================================================
// Shell Placement
// ============================================================================
/**
* ShellPlacement Component
* Helper component for placing children in specific shell sections
*
* Usage:
* Sidebar content
* Header content
*
* @param {Object} props
* @param {string} props.placement - 'leftSide' | 'rightSide' | 'header' | 'footer' | 'mainContent'
* @param {React.ReactNode} props.children - Content to place
*/
export function ShellPlacement({ placement = 'mainContent', children }) {
// Clone children and add placement prop
return React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
placement,
shellPlacement: placement
});
}
return child;
});
}
// ============================================================================
// Exports
// ============================================================================
// Attach static properties
ShellProvider.Manager = shellManager;
ShellProvider.ToastManager = toastManager;
ShellProvider.NotificationManager = notificationCenterManager;
ShellProvider.Context = ShellContext;
ShellProvider.Placement = ShellPlacement;
export default ShellProvider;
export { shellManager as ShellManager, toastManager as ToastManager, notificationCenterManager as NotificationManager };
export { ShellContext };
// ShellPlacement is already exported as a function declaration above