Unify records model and add notification center
This commit is contained in:
+409
-26
@@ -6,7 +6,9 @@
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import { XStack, YStack, Text, Button } from 'tamagui';
|
||||
import RecordsModel from '../../data/RecordsModel.js';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { SidePanelShell } from './SidePanelShell.jsx';
|
||||
|
||||
// ============================================================================
|
||||
// Shell Context
|
||||
@@ -198,7 +200,8 @@ class ToastManager {
|
||||
show(title, message = '', options = {}) {
|
||||
const {
|
||||
type = 'info',
|
||||
duration = this._defaultDuration
|
||||
duration = this._defaultDuration,
|
||||
persistToNotifications = true
|
||||
} = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
@@ -208,6 +211,7 @@ class ToastManager {
|
||||
message,
|
||||
type,
|
||||
duration,
|
||||
persistToNotifications,
|
||||
timestamp: startTime,
|
||||
startTime: startTime // Store start time for pause/resume calculations
|
||||
};
|
||||
@@ -226,6 +230,9 @@ class ToastManager {
|
||||
// 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);
|
||||
@@ -355,11 +362,241 @@ class ToastManager {
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -389,6 +626,7 @@ export function ShellProvider({
|
||||
|
||||
// Toast state
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(notificationCenterManager.isOpen());
|
||||
|
||||
// Initialize shell manager with setters
|
||||
useEffect(() => {
|
||||
@@ -405,6 +643,10 @@ export function ShellProvider({
|
||||
toastManager._init(setToasts);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
notificationCenterManager._init(setNotificationsOpen);
|
||||
}, []);
|
||||
|
||||
// Update shell manager state when it changes
|
||||
useEffect(() => {
|
||||
shellManager._updateState({
|
||||
@@ -469,6 +711,7 @@ export function ShellProvider({
|
||||
return (
|
||||
<ShellContext.Provider value={value}>
|
||||
{children}
|
||||
<NotificationCenterPanel open={notificationsOpen} />
|
||||
</ShellContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -518,30 +761,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
||||
};
|
||||
|
||||
// Type-specific styling
|
||||
const typeStyles = {
|
||||
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'
|
||||
}
|
||||
};
|
||||
|
||||
const style = typeStyles[toast.type] || typeStyles.info;
|
||||
const style = getNotificationTypeStyle(toast.type);
|
||||
const Icon = getIcon(style.icon);
|
||||
|
||||
return (
|
||||
@@ -603,6 +823,168 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
||||
);
|
||||
}
|
||||
|
||||
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="$4"
|
||||
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)
|
||||
@@ -677,11 +1059,12 @@ export function ShellPlacement({ placement = 'mainContent', children }) {
|
||||
// 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 };
|
||||
export { shellManager as ShellManager, toastManager as ToastManager, notificationCenterManager as NotificationManager };
|
||||
export { ShellContext };
|
||||
// ShellPlacement is already exported as a function declaration above
|
||||
|
||||
|
||||
Reference in New Issue
Block a user