cynx-ui 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +0 -3
- package/package.json +1 -1
- package/components/DeleteConfirmModal.jsx +0 -119
- package/components/Layout.jsx +0 -128
- package/components/Notifications.jsx +0 -118
package/index.js
CHANGED
|
@@ -4,15 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
export { default as Button } from './components/Button.jsx';
|
|
6
6
|
export { default as Input } from './components/Input.jsx';
|
|
7
|
-
export { default as Layout } from './components/Layout.jsx';
|
|
8
7
|
export { default as Toggle } from './components/Toggle.jsx';
|
|
9
8
|
export { default as Spinner } from './components/Spinner.jsx';
|
|
10
9
|
export { default as TabBar } from './components/TabBar.jsx';
|
|
11
10
|
export { default as Dropdown } from './components/Dropdown.jsx';
|
|
12
|
-
export { default as Notifications } from './components/Notifications.jsx';
|
|
13
11
|
export { default as MultiSelect } from './components/MultiSelect.jsx';
|
|
14
12
|
export { default as ImageUpload } from './components/ImageUpload.jsx';
|
|
15
|
-
export { default as DeleteConfirmModal } from './components/DeleteConfirmModal.jsx';
|
|
16
13
|
export { default as ChipSelect } from './components/ChipSelect.jsx';
|
|
17
14
|
export { default as Checkbox } from './components/Checkbox.jsx';
|
|
18
15
|
export { default as AuthOverlay } from './components/AuthOverlay.jsx';
|
package/package.json
CHANGED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import Modal from './Modal.jsx';
|
|
3
|
-
import { useAuth } from '../hooks/useAuth.jsx';
|
|
4
|
-
import Input from './Input.jsx';
|
|
5
|
-
import Spinner from './Spinner.jsx';
|
|
6
|
-
|
|
7
|
-
export default function DeleteConfirmModal({ title, warning, confirm: confirmMsg, matchText, onConfirm, onClose }) {
|
|
8
|
-
const { prefs } = useAuth();
|
|
9
|
-
const mode = prefs?.delete_confirm_mode || 'password';
|
|
10
|
-
const confirmText = prefs?.delete_confirm_text || 'delete';
|
|
11
|
-
const [step, setStep] = useState(1);
|
|
12
|
-
const [value, setValue] = useState('');
|
|
13
|
-
const [error, setError] = useState('');
|
|
14
|
-
const [loading, setLoading] = useState(false);
|
|
15
|
-
const [showPass, setShowPass] = useState(false);
|
|
16
|
-
|
|
17
|
-
async function handleConfirm() {
|
|
18
|
-
setError('');
|
|
19
|
-
if (mode === 'text' && value !== confirmText) {
|
|
20
|
-
setError(`Type "${confirmText}" exactly to confirm`);
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
if (mode === 'password' && !value) {
|
|
24
|
-
setError('Password required');
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
setLoading(true);
|
|
28
|
-
try {
|
|
29
|
-
await onConfirm(mode === 'password' ? value : null);
|
|
30
|
-
} catch (e) {
|
|
31
|
-
setError(e.message || 'Confirmation failed');
|
|
32
|
-
} finally {
|
|
33
|
-
setLoading(false);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const EyeIcon = () => (
|
|
38
|
-
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
39
|
-
{showPass
|
|
40
|
-
? <><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></>
|
|
41
|
-
: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></>
|
|
42
|
-
}
|
|
43
|
-
</svg>
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<Modal
|
|
48
|
-
title={title}
|
|
49
|
-
onClose={onClose}
|
|
50
|
-
footer={close => (
|
|
51
|
-
step === 1 ? (
|
|
52
|
-
<>
|
|
53
|
-
<button className="btn btn-ghost" onClick={close}>Cancel</button>
|
|
54
|
-
<button className="btn btn-danger" onClick={() => setStep(2)}>Continue</button>
|
|
55
|
-
</>
|
|
56
|
-
) : (
|
|
57
|
-
<>
|
|
58
|
-
<button className="btn btn-ghost" onClick={onClose}>Cancel</button>
|
|
59
|
-
<button className="btn btn-danger" onClick={handleConfirm} disabled={loading || !value}>
|
|
60
|
-
{loading ? <><Spinner inline /> Deleting...</> : 'Delete'}
|
|
61
|
-
</button>
|
|
62
|
-
</>
|
|
63
|
-
)
|
|
64
|
-
)}
|
|
65
|
-
>
|
|
66
|
-
{step === 1 ? (
|
|
67
|
-
<div style={{ display:'flex', gap:12, alignItems:'flex-start' }}>
|
|
68
|
-
<div style={{ width:36, height:36, borderRadius:'50%', background:'var(--danger-bg, #fff0f0)', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>
|
|
69
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
70
|
-
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
|
71
|
-
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
72
|
-
</svg>
|
|
73
|
-
</div>
|
|
74
|
-
<div style={{ fontSize:'.88rem', lineHeight:1.6 }}>{warning}</div>
|
|
75
|
-
</div>
|
|
76
|
-
) : (
|
|
77
|
-
<div>
|
|
78
|
-
<p style={{ fontSize:'.88rem', marginBottom:16, lineHeight:1.6 }}>{confirmMsg}</p>
|
|
79
|
-
{mode === 'password' ? (
|
|
80
|
-
<div className="form-group" style={{ marginBottom:0 }}>
|
|
81
|
-
<label>Enter your password to confirm</label>
|
|
82
|
-
<div style={{ position:'relative' }}>
|
|
83
|
-
<Input
|
|
84
|
-
type={showPass ? 'text' : 'password'}
|
|
85
|
-
placeholder="Your account password"
|
|
86
|
-
value={value}
|
|
87
|
-
onChange={e => setValue(e.target.value)}
|
|
88
|
-
onKeyDown={e => e.key === 'Enter' && handleConfirm()}
|
|
89
|
-
style={{ width:'100%', paddingRight:36 }}
|
|
90
|
-
autoFocus
|
|
91
|
-
/>
|
|
92
|
-
<button
|
|
93
|
-
type="button"
|
|
94
|
-
onClick={() => setShowPass(v => !v)}
|
|
95
|
-
style={{ position:'absolute', right:10, top:'50%', transform:'translateY(-50%)', background:'none', border:'none', cursor:'pointer', color:'var(--muted)', padding:0, display:'flex', alignItems:'center' }}
|
|
96
|
-
>
|
|
97
|
-
<EyeIcon />
|
|
98
|
-
</button>
|
|
99
|
-
</div>
|
|
100
|
-
</div>
|
|
101
|
-
) : (
|
|
102
|
-
<div className="form-group" style={{ marginBottom:0 }}>
|
|
103
|
-
<label>Type <strong>{confirmText}</strong> to confirm</label>
|
|
104
|
-
<Input
|
|
105
|
-
type="text"
|
|
106
|
-
placeholder={confirmText}
|
|
107
|
-
value={value}
|
|
108
|
-
onChange={e => setValue(e.target.value)}
|
|
109
|
-
onKeyDown={e => e.key === 'Enter' && handleConfirm()}
|
|
110
|
-
autoFocus
|
|
111
|
-
/>
|
|
112
|
-
</div>
|
|
113
|
-
)}
|
|
114
|
-
{error && <div style={{ color:'var(--danger)', fontSize:'.78rem', marginTop:8 }}>{error}</div>}
|
|
115
|
-
</div>
|
|
116
|
-
)}
|
|
117
|
-
</Modal>
|
|
118
|
-
);
|
|
119
|
-
}
|
package/components/Layout.jsx
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react';
|
|
2
|
-
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
|
3
|
-
import { useAuth } from '../hooks/useAuth';
|
|
4
|
-
import { useBranding } from '../hooks/useBranding';
|
|
5
|
-
import { getDevices } from '../lib/api';
|
|
6
|
-
import useSSE from '../hooks/useSSE';
|
|
7
|
-
import { SSEProvider } from '../hooks/SSEContext';
|
|
8
|
-
import StatsBar from './StatsBar';
|
|
9
|
-
import Input from './Input';
|
|
10
|
-
import {
|
|
11
|
-
LayoutDashboard, Monitor, FileText,
|
|
12
|
-
Settings, LogOut, Bell, Search,
|
|
13
|
-
Cpu, Usb
|
|
14
|
-
} from 'lucide-react';
|
|
15
|
-
|
|
16
|
-
const NAV = [
|
|
17
|
-
{ label: 'Dashboard', to: '/admin/dashboard', icon: LayoutDashboard, perm: 'dashboard.view' },
|
|
18
|
-
{ label: 'Devices', to: '/admin/devices', icon: Monitor, perm: 'devices.view_list' },
|
|
19
|
-
// { label: 'Audit Log', to: '/admin/audit', icon: FileText, perm: 'audit.view_logs' },
|
|
20
|
-
{ label: 'Firmware', to: '/admin/firmware', icon: Cpu, perm: 'firmware.view_list' },
|
|
21
|
-
{ label: 'Settings', to: '/admin/settings', icon: Settings, permAny: ['settings.roles.view','settings.admins.view_all','settings.admins.view_own','settings.branding.app_name','settings.sso.add','settings.general.token_expiry','settings.generate.keypair','settings.view'] },
|
|
22
|
-
];
|
|
23
|
-
|
|
24
|
-
function BrandIcon() {
|
|
25
|
-
return (
|
|
26
|
-
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
27
|
-
<rect x="1" y="10" width="2.5" height="5" rx="1" fill="rgba(255,255,255,.55)"/>
|
|
28
|
-
<rect x="4.5" y="7" width="2.5" height="8" rx="1" fill="rgba(255,255,255,.7)"/>
|
|
29
|
-
<rect x="8" y="4" width="2.5" height="11" rx="1" fill="rgba(255,255,255,.85)"/>
|
|
30
|
-
<rect x="11.5" y="1" width="2.5" height="14" rx="1" fill="#fff"/>
|
|
31
|
-
</svg>
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export default function Layout() {
|
|
36
|
-
const { user, logout, hasPermission, hasAnyPermission } = useAuth();
|
|
37
|
-
const branding = useBranding();
|
|
38
|
-
const navigate = useNavigate();
|
|
39
|
-
const [stats, setStats] = useState({ total: 0, active: 0, blocked: 0, actions: 0 });
|
|
40
|
-
const [server, setServer] = useState(null);
|
|
41
|
-
|
|
42
|
-
useSSE('/api/events', {
|
|
43
|
-
'dashboard': (data) => {
|
|
44
|
-
setStats({
|
|
45
|
-
total: data.totalDevices || 0,
|
|
46
|
-
active: (data.totalDevices || 0) - (data.totalBlocked || 0),
|
|
47
|
-
blocked: data.totalBlocked || 0,
|
|
48
|
-
actions: data.totalActions || 0,
|
|
49
|
-
});
|
|
50
|
-
if (data.server) setServer(data.server);
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
function handleLogout() { logout(); navigate('/admin/login'); }
|
|
55
|
-
|
|
56
|
-
const initial = (user?.username || 'A')[0].toUpperCase();
|
|
57
|
-
const appName = branding.app_name || 'CAN';
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<div className="layout">
|
|
61
|
-
<aside className="sidebar">
|
|
62
|
-
<div className="sidebar-logo">
|
|
63
|
-
<div className="sidebar-logo-inner">
|
|
64
|
-
{branding.logo_light ? (
|
|
65
|
-
<img src={branding.logo_light} alt={appName}
|
|
66
|
-
style={{ height: 28, maxWidth: 120, objectFit: 'contain', flexShrink: 0 }} />
|
|
67
|
-
) : (
|
|
68
|
-
<>
|
|
69
|
-
<div className="sidebar-logo-icon brand-icon"><BrandIcon /></div>
|
|
70
|
-
<span className="sidebar-logo-name">{appName}</span>
|
|
71
|
-
</>
|
|
72
|
-
)}
|
|
73
|
-
</div>
|
|
74
|
-
</div>
|
|
75
|
-
|
|
76
|
-
<nav className="sidebar-nav">
|
|
77
|
-
{NAV.filter(item => {
|
|
78
|
-
if (item.permAny) return hasAnyPermission(item.permAny);
|
|
79
|
-
return !item.perm || hasPermission(item.perm);
|
|
80
|
-
}).map(item => (
|
|
81
|
-
<NavLink key={item.to} to={item.to} end={item.end}
|
|
82
|
-
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}>
|
|
83
|
-
<item.icon size={15} />
|
|
84
|
-
{item.label}
|
|
85
|
-
</NavLink>
|
|
86
|
-
))}
|
|
87
|
-
</nav>
|
|
88
|
-
|
|
89
|
-
<div className="sidebar-footer">
|
|
90
|
-
<div className="sidebar-user" onClick={handleLogout} title="Logout">
|
|
91
|
-
<div className="avatar">{initial}</div>
|
|
92
|
-
<div style={{ flex: 1, minWidth: 0 }}>
|
|
93
|
-
<div className="user-name">{user?.username}</div>
|
|
94
|
-
<div className="user-role">{user?.role}</div>
|
|
95
|
-
</div>
|
|
96
|
-
<LogOut size={13} color="rgba(255,255,255,.3)" />
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
</aside>
|
|
100
|
-
|
|
101
|
-
<div className="main">
|
|
102
|
-
<header className="topbar">
|
|
103
|
-
<div className="topbar-left">
|
|
104
|
-
<Input
|
|
105
|
-
size="sm"
|
|
106
|
-
placeholder="Search device"
|
|
107
|
-
icon={<Search size={14} />}
|
|
108
|
-
style={{ width: 220, background: 'var(--surface)', borderRadius: 8 }}
|
|
109
|
-
/>
|
|
110
|
-
</div>
|
|
111
|
-
<div className="topbar-center">
|
|
112
|
-
<StatsBar {...stats} />
|
|
113
|
-
</div>
|
|
114
|
-
<div className="topbar-right">
|
|
115
|
-
<button className="topbar-icon-btn" title="Notifications">
|
|
116
|
-
<Bell size={18} />
|
|
117
|
-
</button>
|
|
118
|
-
</div>
|
|
119
|
-
</header>
|
|
120
|
-
<div className="content">
|
|
121
|
-
<SSEProvider value={{ devices: null, stats, server }}>
|
|
122
|
-
<Outlet />
|
|
123
|
-
</SSEProvider>
|
|
124
|
-
</div>
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
);
|
|
128
|
-
}
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useRef } from 'react';
|
|
2
|
-
import { useEvents } from '../hooks/useEvents.jsx';
|
|
3
|
-
|
|
4
|
-
const MAX_NOTIFS = 50;
|
|
5
|
-
|
|
6
|
-
function eventToNotif(type, data) {
|
|
7
|
-
const id = Date.now() + Math.random();
|
|
8
|
-
const now = new Date();
|
|
9
|
-
switch (type) {
|
|
10
|
-
// Phase B: wire up notification types for new events (passkey registered,
|
|
11
|
-
// new sign-in detected, one-time code requested, etc.) here.
|
|
12
|
-
default:
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export default function Notifications() {
|
|
18
|
-
const [notifs, setNotifs] = useState([]);
|
|
19
|
-
const [open, setOpen] = useState(false);
|
|
20
|
-
const ref = useRef(null);
|
|
21
|
-
|
|
22
|
-
const unread = notifs.filter(n => !n.read).length;
|
|
23
|
-
|
|
24
|
-
useEvents({});
|
|
25
|
-
|
|
26
|
-
function addNotif(type, data) {
|
|
27
|
-
const n = eventToNotif(type, data);
|
|
28
|
-
if (!n) return;
|
|
29
|
-
setNotifs(prev => [n, ...prev].slice(0, MAX_NOTIFS));
|
|
30
|
-
// Browser notification if permission granted
|
|
31
|
-
if ('Notification' in window && Notification.permission === 'granted') {
|
|
32
|
-
new Notification(n.title, { body: n.message });
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function markAllRead() {
|
|
37
|
-
setNotifs(prev => prev.map(n => ({ ...n, read: true })));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function clearAll() {
|
|
41
|
-
setNotifs([]);
|
|
42
|
-
setOpen(false);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Close on outside click
|
|
46
|
-
useEffect(() => {
|
|
47
|
-
function handler(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
|
|
48
|
-
document.addEventListener('mousedown', handler);
|
|
49
|
-
return () => document.removeEventListener('mousedown', handler);
|
|
50
|
-
}, []);
|
|
51
|
-
|
|
52
|
-
function timeAgo(date) {
|
|
53
|
-
const s = Math.floor((Date.now() - date) / 1000);
|
|
54
|
-
if (s < 60) return `${s}s ago`;
|
|
55
|
-
if (s < 3600) return `${Math.floor(s/60)}m ago`;
|
|
56
|
-
if (s < 86400)return `${Math.floor(s/3600)}h ago`;
|
|
57
|
-
return `${Math.floor(s/86400)}d ago`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const iconFor = () => (
|
|
61
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
<div ref={ref} style={{ position:'relative', height:'100%', display:'flex', alignItems:'center' }}>
|
|
66
|
-
<button
|
|
67
|
-
onClick={() => { setOpen(o => !o); if (!open && unread) markAllRead(); }}
|
|
68
|
-
style={{ position:'relative', background:'none', border:'none', cursor:'pointer', color:'var(--muted)', padding:'6px', display:'flex', alignItems:'center', borderRadius:'var(--rads)', transition:'color .15s' }}
|
|
69
|
-
onMouseEnter={e => e.currentTarget.style.color = 'var(--primary-text)'}
|
|
70
|
-
onMouseLeave={e => e.currentTarget.style.color = 'var(--muted)'}
|
|
71
|
-
>
|
|
72
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
73
|
-
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
|
74
|
-
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|
75
|
-
</svg>
|
|
76
|
-
{unread > 0 && (
|
|
77
|
-
<span style={{ position:'absolute', top:2, right:2, minWidth:16, height:16, borderRadius:8, background:'var(--danger)', color:'#fff', fontSize:10, fontWeight:700, display:'flex', alignItems:'center', justifyContent:'center', padding:'0 3px', lineHeight:1 }}>
|
|
78
|
-
{unread > 99 ? '99+' : unread}
|
|
79
|
-
</span>
|
|
80
|
-
)}
|
|
81
|
-
</button>
|
|
82
|
-
|
|
83
|
-
<div style={{
|
|
84
|
-
position:'absolute', right:0, top:'100%', width:320, zIndex:1000,
|
|
85
|
-
background:'var(--surface)', border:'1px solid var(--border)',
|
|
86
|
-
borderRadius:'var(--rads)', boxShadow:'var(--shadow-lg)',
|
|
87
|
-
opacity: open ? 1 : 0,
|
|
88
|
-
transform: open ? 'translateY(0)' : 'translateY(-6px)',
|
|
89
|
-
pointerEvents: open ? 'auto' : 'none',
|
|
90
|
-
transition: 'opacity .15s ease, transform .15s ease',
|
|
91
|
-
}}>
|
|
92
|
-
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'10px 14px', borderBottom:'1px solid var(--border)' }}>
|
|
93
|
-
<span style={{ fontSize:'.82rem', fontWeight:600 }}>Notifications</span>
|
|
94
|
-
{notifs.length > 0 && (
|
|
95
|
-
<button onClick={clearAll} style={{ fontSize:'.7rem', color:'var(--muted)', background:'none', border:'none', cursor:'pointer' }}>Clear all</button>
|
|
96
|
-
)}
|
|
97
|
-
</div>
|
|
98
|
-
|
|
99
|
-
<div style={{ maxHeight:360, overflowY:'auto' }}>
|
|
100
|
-
{notifs.length === 0 ? (
|
|
101
|
-
<div style={{ padding:'24px 16px', textAlign:'center', color:'var(--muted)', fontSize:'.8rem' }}>No notifications</div>
|
|
102
|
-
) : notifs.map(n => (
|
|
103
|
-
<div key={n.id} style={{ display:'flex', gap:10, padding:'10px 14px', borderBottom:'1px solid var(--border-light)', background: n.read ? 'transparent' : 'var(--accent-bg)', transition:'background .2s' }}>
|
|
104
|
-
<div style={{ flexShrink:0, width:28, height:28, borderRadius:'50%', background:'var(--grey-1)', display:'flex', alignItems:'center', justifyContent:'center', color:'var(--accent)', marginTop:1 }}>
|
|
105
|
-
{iconFor(n.type)}
|
|
106
|
-
</div>
|
|
107
|
-
<div style={{ flex:1, minWidth:0 }}>
|
|
108
|
-
<div style={{ fontSize:'.78rem', fontWeight:600, marginBottom:2 }}>{n.title}</div>
|
|
109
|
-
<div style={{ fontSize:'.72rem', color:'var(--muted)', lineHeight:1.4 }}>{n.message}</div>
|
|
110
|
-
<div style={{ fontSize:'.68rem', color:'var(--muted)', marginTop:4, opacity:.7 }}>{timeAgo(n.time)}</div>
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
))}
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
|
-
</div>
|
|
117
|
-
);
|
|
118
|
-
}
|