Adds API filter registry, style theme registry, SW bitmask cache clear, KV namespacing, session expiry checks, accessibility improvements, and expanded test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
1123 lines
28 KiB
JavaScript
1123 lines
28 KiB
JavaScript
/**
|
||
* 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 (
|
||
<ShellContext.Provider value={value}>
|
||
{children}
|
||
<NetworkActivityOverlay visible={networkActivity.visible} />
|
||
<NotificationCenterPanel open={notificationsOpen} />
|
||
</ShellContext.Provider>
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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 (
|
||
<XStack
|
||
backgroundColor={style.backgroundColor}
|
||
borderWidth={1}
|
||
borderColor={style.borderColor}
|
||
borderRadius="$radiusMd"
|
||
padding="$3"
|
||
minWidth={300}
|
||
maxWidth={400}
|
||
shadowColor="$shadowColor"
|
||
shadowOffset={{ width: 0, height: 2 }}
|
||
shadowOpacity={0.1}
|
||
shadowRadius={8}
|
||
elevation={4}
|
||
gap="$2"
|
||
onPointerEnter={() => 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 ? (
|
||
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}>
|
||
<Icon size="md" color="$textSecondary" />
|
||
</XStack>
|
||
) : null}
|
||
<YStack flex={1} gap="$1">
|
||
{toast.title && (
|
||
<Text fontWeight="600" fontSize="$4" color="$color">
|
||
{toast.title}
|
||
</Text>
|
||
)}
|
||
{toast.message && (
|
||
<Text fontSize="$3" color="$textSecondary">
|
||
{toast.message}
|
||
</Text>
|
||
)}
|
||
</YStack>
|
||
<Button
|
||
size="$2"
|
||
circular
|
||
chromeless
|
||
onPress={handleClose}
|
||
aria-label="Close toast"
|
||
>
|
||
{(() => {
|
||
const CloseIcon = getIcon('close');
|
||
return CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">×</Text>;
|
||
})()}
|
||
</Button>
|
||
</XStack>
|
||
);
|
||
}
|
||
|
||
function NotificationRecord({ record, onDismiss }) {
|
||
const style = getNotificationTypeStyle(record.type);
|
||
const Icon = getIcon(style.icon);
|
||
const CloseIcon = getIcon('close');
|
||
|
||
return (
|
||
<XStack
|
||
backgroundColor={style.backgroundColor}
|
||
borderWidth={1}
|
||
borderColor={style.borderColor}
|
||
borderRadius="$radiusMd"
|
||
padding="$3"
|
||
gap="$2"
|
||
alignItems="flex-start"
|
||
>
|
||
{Icon ? (
|
||
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0} marginTop="$1">
|
||
<Icon size="md" color="$textSecondary" />
|
||
</XStack>
|
||
) : null}
|
||
<YStack flex={1} gap="$1">
|
||
{record.title ? (
|
||
<Text fontWeight="600" fontSize="$4" color="$color">
|
||
{record.title}
|
||
</Text>
|
||
) : null}
|
||
{record.message ? (
|
||
<Text fontSize="$3" color="$textSecondary">
|
||
{record.message}
|
||
</Text>
|
||
) : null}
|
||
{record.timestamp ? (
|
||
<Text fontSize="$2" color="$textMuted">
|
||
{new Date(record.timestamp).toLocaleString()}
|
||
</Text>
|
||
) : null}
|
||
{record.action_label ? (
|
||
<Text fontSize="$2" color="$accent">
|
||
{record.action_label}
|
||
</Text>
|
||
) : null}
|
||
</YStack>
|
||
<Button
|
||
size="$2"
|
||
circular
|
||
chromeless
|
||
onPress={() => onDismiss?.(record.id)}
|
||
aria-label="Dismiss notification"
|
||
icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
|
||
/>
|
||
</XStack>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<SidePanelShell
|
||
open={open}
|
||
onClose={() => notificationCenterManager.close()}
|
||
title="Notifications"
|
||
width={360}
|
||
toolbar={records.length > 0 ? [
|
||
{
|
||
id: 'clear-notifications',
|
||
label: 'Clear All',
|
||
chromeless: true,
|
||
onPress: () => notificationCenterManager.clear()
|
||
}
|
||
] : []}
|
||
>
|
||
{error ? (
|
||
<Text color="$danger" fontWeight="600">
|
||
{error}
|
||
</Text>
|
||
) : null}
|
||
|
||
{loading ? (
|
||
<Text color="$textMuted">Loading notifications...</Text>
|
||
) : null}
|
||
|
||
{!loading && records.length === 0 ? (
|
||
<YStack gap="$2">
|
||
<Text fontWeight="600" color="$textPrimary">No pending notifications</Text>
|
||
<Text color="$textMuted">Expired toast messages will appear here until dismissed.</Text>
|
||
</YStack>
|
||
) : null}
|
||
|
||
{records.map((record) => (
|
||
<NotificationRecord
|
||
key={record.id}
|
||
record={record}
|
||
onDismiss={(id) => notificationCenterManager.dismiss(id)}
|
||
/>
|
||
))}
|
||
</SidePanelShell>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<YStack
|
||
position="fixed"
|
||
bottom="$4"
|
||
right="$4"
|
||
gap="$2"
|
||
zIndex={22000}
|
||
pointerEvents="none"
|
||
maxWidth="calc(100vw - 32px)"
|
||
>
|
||
{toast.toasts.map((toastItem) => (
|
||
<XStack
|
||
key={toastItem.id}
|
||
pointerEvents="auto"
|
||
>
|
||
<Toast
|
||
toast={toastItem}
|
||
onClose={toast.hide}
|
||
onPause={toast.pause}
|
||
onResume={toast.resume}
|
||
/>
|
||
</XStack>
|
||
))}
|
||
</YStack>
|
||
);
|
||
}
|
||
|
||
function NetworkActivityOverlay({ visible = false }) {
|
||
if (!visible) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<YStack
|
||
role="status"
|
||
aria-live="polite"
|
||
aria-busy
|
||
accessibilityLabel="Network activity in progress"
|
||
position="fixed"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
bottom={0}
|
||
zIndex={21500}
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
pointerEvents="none"
|
||
>
|
||
<YStack
|
||
padding="$4"
|
||
gap="$2"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
backgroundColor="$bgPanel"
|
||
borderWidth={1}
|
||
borderColor="$lineSubtle"
|
||
borderRadius="$radiusLg"
|
||
shadowColor="$shadowColor"
|
||
shadowOpacity={0.15}
|
||
shadowRadius={12}
|
||
elevation={6}
|
||
>
|
||
<Spinner size="large" color="$accentColor" />
|
||
<Text color="$textSecondary" fontSize="$3">
|
||
Working...
|
||
</Text>
|
||
</YStack>
|
||
</YStack>
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Shell Placement
|
||
// ============================================================================
|
||
|
||
/**
|
||
* ShellPlacement Component
|
||
* Helper component for placing children in specific shell sections
|
||
*
|
||
* Usage:
|
||
* <ShellPlacement placement="leftSide">Sidebar content</ShellPlacement>
|
||
* <ShellPlacement placement="header">Header content</ShellPlacement>
|
||
*
|
||
* @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
|
||
|