cynx-lib 1.3.28

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.
Files changed (46) hide show
  1. package/index.js +3 -0
  2. package/package.json +36 -0
  3. package/ui/components/ActionsMenu.jsx +158 -0
  4. package/ui/components/ActivityCard.jsx +184 -0
  5. package/ui/components/AuthOverlay.jsx +94 -0
  6. package/ui/components/Badge.jsx +186 -0
  7. package/ui/components/BrandingCard.jsx +146 -0
  8. package/ui/components/Button.jsx +148 -0
  9. package/ui/components/Card.jsx +132 -0
  10. package/ui/components/Checkbox.jsx +59 -0
  11. package/ui/components/ChipSelect.jsx +54 -0
  12. package/ui/components/CircularProgress.jsx +216 -0
  13. package/ui/components/Confirm.jsx +79 -0
  14. package/ui/components/DatePicker.jsx +605 -0
  15. package/ui/components/Divider.jsx +96 -0
  16. package/ui/components/Dropzone.jsx +203 -0
  17. package/ui/components/FilePicker.jsx +205 -0
  18. package/ui/components/HoverCard.jsx +81 -0
  19. package/ui/components/ImageUpload.jsx +147 -0
  20. package/ui/components/InfoBadge.jsx +204 -0
  21. package/ui/components/Input.jsx +229 -0
  22. package/ui/components/LineProgress.jsx +133 -0
  23. package/ui/components/LinearProgress.jsx +132 -0
  24. package/ui/components/Modal.jsx +212 -0
  25. package/ui/components/Pagination.jsx +52 -0
  26. package/ui/components/PulseDot.jsx +77 -0
  27. package/ui/components/Select.jsx +415 -0
  28. package/ui/components/Skeleton.jsx +118 -0
  29. package/ui/components/Spinner.jsx +108 -0
  30. package/ui/components/StatBarCard.jsx +25 -0
  31. package/ui/components/StatsBar.jsx +88 -0
  32. package/ui/components/StatsCard.jsx +171 -0
  33. package/ui/components/TabBar.jsx +404 -0
  34. package/ui/components/Tabs.jsx +29 -0
  35. package/ui/components/TimePicker.jsx +1069 -0
  36. package/ui/components/Toast.jsx +106 -0
  37. package/ui/components/Toggle.jsx +67 -0
  38. package/ui/components/UptimeBar.jsx +135 -0
  39. package/ui/components/XTable.jsx +44 -0
  40. package/ui/index.js +97 -0
  41. package/utils/index.js +11 -0
  42. package/utils/utils/oidc.js +209 -0
  43. package/utils/utils/realtime.js +1040 -0
  44. package/utils/utils/storage.js +393 -0
  45. package/utils/utils/tz.js +625 -0
  46. package/utils/utils/uid.js +252 -0
@@ -0,0 +1,133 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ const SIZES = {
4
+ xs: { height: 4, fontSize: '.68rem' },
5
+ sm: { height: 5, fontSize: '.75rem' },
6
+ md: { height: 6, fontSize: '.82rem' },
7
+ lg: { height: 8, fontSize: '.9rem' },
8
+ xl: { height: 10, fontSize: '1rem' },
9
+ };
10
+
11
+ export default function LineProgress({
12
+ value = 0,
13
+ segments,
14
+ size = 'md',
15
+ customSize,
16
+ color = 'var(--blue, #2782e4)',
17
+ trackColor = 'var(--bd2, #e0e0e4)',
18
+ label = '',
19
+ showPercent = true,
20
+ animated = true,
21
+ rounded = true,
22
+ indeterminate = false,
23
+ speed = 1,
24
+ height,
25
+ gap = 5,
26
+ labelGap = 2,
27
+ className = '',
28
+ style = {},
29
+ }) {
30
+ const base = SIZES[size] || SIZES.md;
31
+ const barHeight = height ?? (customSize || base.height);
32
+ const fontSize = customSize ? `${Math.max(11, customSize * 1.6)}px` : base.fontSize;
33
+ const pct = Math.min(100, Math.max(0, value));
34
+ const radius = rounded ? barHeight / 2 : 0;
35
+
36
+ const barRef = useRef(null);
37
+
38
+ // Indeterminate animation
39
+ useEffect(() => {
40
+ if (!indeterminate || !barRef.current) return;
41
+ let pos = -40, direction = 1, raf;
42
+ let lastTime = performance.now();
43
+
44
+ function tick(now) {
45
+ const dt = (now - lastTime) / 16.67;
46
+ lastTime = now;
47
+ pos += direction * 1.2 * speed * dt;
48
+ if (pos > 100) { pos = 100; direction = -1; }
49
+ if (pos < -40) { pos = -40; direction = 1; }
50
+ if (barRef.current) {
51
+ barRef.current.style.left = pos + '%';
52
+ barRef.current.style.width = '40%';
53
+ }
54
+ raf = requestAnimationFrame(tick);
55
+ }
56
+ raf = requestAnimationFrame(tick);
57
+ return () => cancelAnimationFrame(raf);
58
+ }, [indeterminate, speed]);
59
+
60
+ const transitionStyle = animated ? 'flex .5s cubic-bezier(0.4,0,0.2,1)' : 'none';
61
+ const hasSegments = Array.isArray(segments) && segments.length > 0;
62
+ const rawSum = hasSegments ? segments.reduce((sum, s) => sum + (Number(s.value) || 0), 0) : 0;
63
+
64
+ return (
65
+ <div className={className} style={{ display: 'inline-flex', flexDirection: 'column', gap: labelGap, width: '100%', ...style }}>
66
+ {(label || showPercent) && (
67
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
68
+ {label && <span style={{ fontSize, fontWeight: 600, color: 'var(--primary-text, #1a1a2e)' }}>{label}</span>}
69
+ {showPercent && !indeterminate && !hasSegments && (
70
+ <span style={{ fontSize, fontWeight: 600, color: 'var(--muted, #9ca3af)', fontVariantNumeric: 'tabular-nums', marginLeft: label ? 0 : 'auto' }}>
71
+ {Math.round(pct)}%
72
+ </span>
73
+ )}
74
+ </div>
75
+ )}
76
+ <div style={{
77
+ height: barHeight, borderRadius: radius,
78
+ background: 'transparent', position: 'relative',
79
+ display: 'flex', gap: gap, overflow: 'hidden',
80
+ }}>
81
+ {indeterminate ? (
82
+ <>
83
+ <div style={{ flex: 1, borderRadius: radius, background: trackColor }} />
84
+ <div
85
+ ref={barRef}
86
+ style={{
87
+ position: 'absolute', top: 0, height: '100%',
88
+ width: '40%', borderRadius: radius,
89
+ background: color, opacity: 0.85,
90
+ }}
91
+ />
92
+ </>
93
+ ) : hasSegments && rawSum > 0 ? (
94
+ <>
95
+ {segments.map((seg, idx) => {
96
+ const segVal = Number(seg.value) || 0;
97
+ if (segVal <= 0) return null;
98
+ const ratioPct = (segVal / rawSum) * 100;
99
+ return (
100
+ <div
101
+ key={idx}
102
+ title={seg.label ? `${seg.label}: ${segVal}` : undefined}
103
+ style={{
104
+ flex: ratioPct,
105
+ height: '100%',
106
+ borderRadius: radius,
107
+ background: seg.color || color,
108
+ transition: transitionStyle,
109
+ }}
110
+ />
111
+ );
112
+ })}
113
+ </>
114
+ ) : (
115
+ <>
116
+ <div ref={barRef} style={{
117
+ flex: pct > 0 ? pct : 0, minWidth: pct > 0 ? barHeight : 0,
118
+ borderRadius: radius, background: color,
119
+ transition: transitionStyle,
120
+ }} />
121
+ {pct < 100 && (
122
+ <div style={{
123
+ flex: 100 - pct,
124
+ borderRadius: radius, background: trackColor,
125
+ transition: transitionStyle,
126
+ }} />
127
+ )}
128
+ </>
129
+ )}
130
+ </div>
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,132 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ const SIZES = {
4
+ xs: { height: 4, fontSize: '.68rem' },
5
+ sm: { height: 5, fontSize: '.75rem' },
6
+ md: { height: 6, fontSize: '.82rem' },
7
+ lg: { height: 8, fontSize: '.9rem' },
8
+ xl: { height: 10, fontSize: '1rem' },
9
+ };
10
+
11
+ export default function LinearProgress({
12
+ value = 0,
13
+ segments,
14
+ size = 'md',
15
+ customSize,
16
+ color = 'var(--blue, #3b82f6)',
17
+ trackColor = 'var(--bd2, #e0e0e4)',
18
+ label = '',
19
+ showPercent = true,
20
+ animated = true,
21
+ rounded = true,
22
+ indeterminate = false,
23
+ speed = 1,
24
+ height,
25
+ gap = 5,
26
+ labelGap = 2,
27
+ className = '',
28
+ style = {},
29
+ }) {
30
+ const base = SIZES[size] || SIZES.md;
31
+ const barHeight = height ?? (customSize || base.height);
32
+ const fontSize = customSize ? `${Math.max(11, customSize * 1.6)}px` : base.fontSize;
33
+ const pct = Math.min(100, Math.max(0, value));
34
+ const radius = rounded ? barHeight / 2 : 0;
35
+
36
+ const barRef = useRef(null);
37
+
38
+ useEffect(() => {
39
+ if (!indeterminate || !barRef.current) return;
40
+ let pos = -40, direction = 1, raf;
41
+ let lastTime = performance.now();
42
+
43
+ function tick(now) {
44
+ const dt = (now - lastTime) / 16.67;
45
+ lastTime = now;
46
+ pos += direction * 1.2 * speed * dt;
47
+ if (pos > 100) { pos = 100; direction = -1; }
48
+ if (pos < -40) { pos = -40; direction = 1; }
49
+ if (barRef.current) {
50
+ barRef.current.style.left = pos + '%';
51
+ barRef.current.style.width = '40%';
52
+ }
53
+ raf = requestAnimationFrame(tick);
54
+ }
55
+ raf = requestAnimationFrame(tick);
56
+ return () => cancelAnimationFrame(raf);
57
+ }, [indeterminate, speed]);
58
+
59
+ const transitionStyle = animated ? 'flex .5s cubic-bezier(0.4,0,0.2,1)' : 'none';
60
+ const hasSegments = Array.isArray(segments) && segments.length > 0;
61
+ const rawSum = hasSegments ? segments.reduce((sum, s) => sum + (Number(s.value) || 0), 0) : 0;
62
+
63
+ return (
64
+ <div className={className} style={{ display: 'inline-flex', flexDirection: 'column', gap: labelGap, width: '100%', ...style }}>
65
+ {(label || showPercent) && (
66
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
67
+ {label && <span style={{ fontSize, fontWeight: 600, color: 'var(--primary-text, #1a1a2e)' }}>{label}</span>}
68
+ {showPercent && !indeterminate && !hasSegments && (
69
+ <span style={{ fontSize, fontWeight: 600, color: 'var(--muted, #9ca3af)', fontVariantNumeric: 'tabular-nums', marginLeft: label ? 0 : 'auto' }}>
70
+ {Math.round(pct)}%
71
+ </span>
72
+ )}
73
+ </div>
74
+ )}
75
+ <div style={{
76
+ height: barHeight, borderRadius: radius,
77
+ background: 'transparent', position: 'relative',
78
+ display: 'flex', gap: gap, overflow: 'hidden',
79
+ }}>
80
+ {indeterminate ? (
81
+ <>
82
+ <div style={{ flex: 1, borderRadius: radius, background: trackColor }} />
83
+ <div
84
+ ref={barRef}
85
+ style={{
86
+ position: 'absolute', top: 0, height: '100%',
87
+ width: '40%', borderRadius: radius,
88
+ background: color, opacity: 0.85,
89
+ }}
90
+ />
91
+ </>
92
+ ) : hasSegments && rawSum > 0 ? (
93
+ <>
94
+ {segments.map((seg, idx) => {
95
+ const segVal = Number(seg.value) || 0;
96
+ if (segVal <= 0) return null;
97
+ const ratioPct = (segVal / rawSum) * 100;
98
+ return (
99
+ <div
100
+ key={idx}
101
+ title={seg.label ? `${seg.label}: ${segVal}` : undefined}
102
+ style={{
103
+ flex: ratioPct,
104
+ height: '100%',
105
+ borderRadius: radius,
106
+ background: seg.color || color,
107
+ transition: transitionStyle,
108
+ }}
109
+ />
110
+ );
111
+ })}
112
+ </>
113
+ ) : (
114
+ <>
115
+ <div ref={barRef} style={{
116
+ flex: pct > 0 ? pct : 0, minWidth: pct > 0 ? barHeight : 0,
117
+ borderRadius: radius, background: color,
118
+ transition: transitionStyle,
119
+ }} />
120
+ {pct < 100 && (
121
+ <div style={{
122
+ flex: 100 - pct,
123
+ borderRadius: radius, background: trackColor,
124
+ transition: transitionStyle,
125
+ }} />
126
+ )}
127
+ </>
128
+ )}
129
+ </div>
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,212 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import Button from './Button';
4
+
5
+ const STYLES = `
6
+ /* ── Modal ─────────────────────────────────────────────────── */
7
+ .modal-backdrop {
8
+ position: fixed; inset: 0;
9
+ background: rgba(20,22,41,.45);
10
+ display: flex; align-items: center; justify-content: center;
11
+ z-index: 8000; padding: 20px;
12
+ }
13
+ .modal {
14
+ background: var(--surface, #ffffff); border: 1px solid var(--border, #e2e8f0);
15
+ border-radius: 14px; width: 100%; max-width: 480px;
16
+ box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.06));
17
+ overflow: visible;
18
+ }
19
+ .modal-hdr {
20
+ display: flex; align-items: center; justify-content: space-between;
21
+ padding: 15px 20px; border-bottom: 1px solid var(--border-light, #ebebf0);
22
+ border-radius: 14px 14px 0 0;
23
+ flex-shrink: 0;
24
+ }
25
+ .modal-title { font-size: .88rem; font-weight: 700; letter-spacing: -.01em; }
26
+ .modal-body { padding: 20px; overflow-x: hidden; overflow-y: auto; flex: 1 1 auto; min-height: 0; }
27
+ .modal-footer {
28
+ display: flex; justify-content: flex-end; gap: 8px;
29
+ padding: 13px 20px; border-top: 1px solid var(--border-light, #ebebf0);
30
+ background: var(--grey-1, #f5f5f8); border-radius: 0 0 14px 14px;
31
+ flex-shrink: 0;
32
+ }
33
+ .modal-card {
34
+ background: var(--surface, #ffffff); border: 1px solid var(--border, #e2e8f0);
35
+ border-radius: 14px; width: 100%; max-width: 480px; max-height: 60vh;
36
+ box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.06)); overflow: hidden;
37
+ display: flex; flex-direction: column;
38
+ }
39
+ .modal-close {
40
+ background: none; border: none; cursor: pointer; padding: 4px; border-radius: 6px;
41
+ color: var(--muted, #797b8d); display: flex; align-items: center; transition: all .14s;
42
+ }
43
+ .modal-close:hover { color: var(--primary-text, #0f172a); background: var(--grey-1, #f5f5f8); }
44
+ .confirm-icon {
45
+ width: 48px; height: 48px; border-radius: 50%; margin: 0 auto 12px;
46
+ display: flex; align-items: center; justify-content: center;
47
+ }
48
+ .confirm-icon.danger { background: var(--dd, rgba(246,71,71,.1)); }
49
+ .confirm-icon.warning { background: var(--pd, rgba(249,158,44,.1)); }
50
+ .confirm-icon.info { background: var(--infd, rgba(39,130,228,.1)); }
51
+
52
+ /* Modal scale */
53
+ @keyframes modalIn {
54
+ from { opacity: 0; transform: scale(.97); }
55
+ to { opacity: 1; transform: scale(1); }
56
+ }
57
+ .modal { animation: modalIn .16s ease; }
58
+
59
+ /* ── Modal open animation ──────────────────────────────────── */
60
+ @keyframes modalPop {
61
+ from { opacity: 0; transform: scale(.96) translateY(-8px); }
62
+ to { opacity: 1; transform: scale(1) translateY(0); }
63
+ }
64
+ @keyframes backdropFade {
65
+ from { opacity: 0; }
66
+ to { opacity: 1; }
67
+ }
68
+
69
+ /* ── Modal close animations ────────────────────────────────── */
70
+ @keyframes modalClose {
71
+ from { opacity: 1; transform: scale(1) translateY(0); }
72
+ to { opacity: 0; transform: scale(.96) translateY(-8px); }
73
+ }
74
+ @keyframes backdropClose {
75
+ from { opacity: 1; }
76
+ to { opacity: 0; }
77
+ }
78
+
79
+ /* ── cynx-ui Modal keyframes ────────────────────────────────── */
80
+ @keyframes modalFadeIn { from { opacity:0 } to { opacity:1 } }
81
+ @keyframes modalFadeOut { from { opacity:1 } to { opacity:0 } }
82
+ @keyframes modalCardIn { from { opacity:0; transform:scale(.96) translateY(-8px) } to { opacity:1; transform:scale(1) translateY(0) } }
83
+ @keyframes modalCardOut { from { opacity:1; transform:scale(1) translateY(0) } to { opacity:0; transform:scale(.96) translateY(-8px) } }
84
+ `;
85
+
86
+ let _id = 'cynx-modal-styles';
87
+ function ensureStyles() {
88
+ if (typeof document === 'undefined') return;
89
+ if (document.getElementById(_id)) return;
90
+ const tag = document.createElement('style');
91
+ tag.id = _id;
92
+ tag.textContent = STYLES;
93
+ document.head.appendChild(tag);
94
+ }
95
+
96
+ // Auto-inject on module load
97
+ if (typeof document !== 'undefined') ensureStyles();
98
+
99
+ export function Modal({ open, onClose, onBeforeClose, title, children, footer, maxWidth = 520, maxHeight, bodyStyle, style }) {
100
+ const [closing, setClosing] = useState(false);
101
+ const mounted = useRef(open);
102
+
103
+ ensureStyles();
104
+
105
+ useEffect(() => {
106
+ if (open) {
107
+ mounted.current = true;
108
+ setClosing(false);
109
+ document.body.style.overflow = 'hidden';
110
+ }
111
+ return () => { document.body.style.overflow = ''; };
112
+ }, [open]);
113
+
114
+ function close() {
115
+ if (closing) return;
116
+ if (onBeforeClose && onBeforeClose() === false) return;
117
+ setClosing(true);
118
+ setTimeout(() => {
119
+ mounted.current = false;
120
+ setClosing(false);
121
+ onClose();
122
+ }, 260);
123
+ }
124
+
125
+ useEffect(() => {
126
+ if (!open) return;
127
+ function onKey(e) { if (e.key === 'Escape') close(); }
128
+ document.addEventListener('keydown', onKey);
129
+ return () => document.removeEventListener('keydown', onKey);
130
+ }, [open, closing]);
131
+
132
+ if (!open && !closing) return null;
133
+
134
+ const resolvedFooter = typeof footer === 'function' ? footer(close) : footer;
135
+ const resolvedChildren = typeof children === 'function' ? children(close) : children;
136
+ const resolvedMaxHeight = style?.maxHeight || maxHeight || '60vh';
137
+
138
+ return createPortal(
139
+ <div
140
+ className="modal-backdrop"
141
+ style={{
142
+ position: 'fixed', inset: 0,
143
+ background: 'rgba(20,22,41,.45)',
144
+ display: 'flex', alignItems: 'center', justifyItems: 'center', justifyContent: 'center',
145
+ zIndex: 8000, padding: 20,
146
+ animation: `${closing ? 'modalFadeOut' : 'modalFadeIn'} .26s ease forwards`,
147
+ }}
148
+ onMouseDown={e => { if (e.target === e.currentTarget) close(); }}
149
+ >
150
+ <div
151
+ className="modal-card"
152
+ style={{
153
+ background: 'var(--surface, #ffffff)', border: '1px solid var(--border, #e2e8f0)',
154
+ borderRadius: 14, width: '100%',
155
+ maxWidth: style?.maxWidth || maxWidth,
156
+ maxHeight: resolvedMaxHeight,
157
+ boxShadow: 'var(--slg, 0 20px 60px rgba(0,0,0,.25))',
158
+ overflow: 'hidden', display: 'flex', flexDirection: 'column',
159
+ animation: `${closing ? 'modalCardOut' : 'modalCardIn'} .26s cubic-bezier(.4,0,.2,1) forwards`,
160
+ ...style,
161
+ }}
162
+ onMouseDown={e => e.stopPropagation()}
163
+ >
164
+ <div className="modal-hdr" style={{
165
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
166
+ padding: '15px 20px', borderBottom: '1px solid var(--border-light, #ebebf0)',
167
+ borderRadius: '14px 14px 0 0', flexShrink: 0,
168
+ }}>
169
+ <span className="modal-title" style={{ fontSize: '.88rem', fontWeight: 700, letterSpacing: '-.01em' }}>{title}</span>
170
+ </div>
171
+ <div className="modal-body" style={{ padding: 20, overflowX: 'hidden', overflowY: 'auto', flex: '1 1 auto', minHeight: 0, ...bodyStyle }}>{resolvedChildren}</div>
172
+ {resolvedFooter && <div className="modal-footer" style={{
173
+ display: 'flex', justifyContent: 'flex-end', gap: 8,
174
+ padding: '13px 20px', borderTop: '1px solid var(--border-light, #ebebf0)',
175
+ background: 'var(--grey-1, #f5f5f8)', borderRadius: '0 0 14px 14px', flexShrink: 0,
176
+ }}>{resolvedFooter}</div>}
177
+ </div>
178
+ </div>,
179
+ document.body
180
+ );
181
+ }
182
+
183
+ export function Confirm({ open, onClose, onConfirm, title, message, variant = 'danger', confirmLabel = 'Confirm', loading = false }) {
184
+ const iconColor = { danger: 'var(--danger, #f64747)', warning: 'var(--accent, #f99e2c)', info: 'var(--blue, #2782e4)' }[variant] || 'var(--danger, #f64747)';
185
+ const btnVariant = variant === 'danger' ? 'danger' : 'primary';
186
+
187
+ const icon = variant === 'danger'
188
+ ? <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="2" strokeLinecap="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
189
+ : <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="2" strokeLinecap="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>;
190
+
191
+ return (
192
+ <Modal open={open} onClose={onClose} title="" maxWidth={400}
193
+ footer={
194
+ <>
195
+ <Button variant="ghost" size="sm" onClick={onClose} disabled={loading}>Cancel</Button>
196
+ <Button variant={btnVariant} size="sm" onClick={onConfirm} disabled={loading}>
197
+ {loading ? 'Please wait...' : confirmLabel}
198
+ </Button>
199
+ </>
200
+ }>
201
+ <div style={{ textAlign: 'center', padding: '4px 0 8px' }}>
202
+ <div className={`confirm-icon ${variant}`} style={{
203
+ width: 48, height: 48, borderRadius: '50%', margin: '0 auto 12px',
204
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
205
+ background: variant === 'danger' ? 'var(--dd, rgba(246,71,71,.1))' : variant === 'warning' ? 'var(--pd, rgba(249,158,44,.1))' : 'var(--infd, rgba(39,130,228,.1))',
206
+ }}>{icon}</div>
207
+ <div style={{ fontSize: '.9rem', fontWeight: 700, marginBottom: 8 }}>{title}</div>
208
+ {message && <div style={{ fontSize: '.8rem', color: 'var(--muted, #797b8d)', lineHeight: 1.5 }}>{message}</div>}
209
+ </div>
210
+ </Modal>
211
+ );
212
+ }
@@ -0,0 +1,52 @@
1
+ import Button from './Button';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+
4
+ export function Pagination({ page = 1, pages = 1, total, perPage, onChange, style = {} }) {
5
+ const totalPages = pages || (total && perPage ? Math.ceil(total / perPage) : 0);
6
+ if (!totalPages || totalPages <= 1) return null;
7
+
8
+ const items = [];
9
+ for (let i = 1; i <= totalPages; i++) {
10
+ if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) items.push(i);
11
+ else if (items[items.length - 1] !== '...') items.push('...');
12
+ }
13
+
14
+ const start = perPage ? (page - 1) * perPage + 1 : null;
15
+ const end = perPage ? Math.min(page * perPage, total) : null;
16
+
17
+ return (
18
+ <div style={{
19
+ display: 'flex',
20
+ alignItems: 'center',
21
+ gap: 8,
22
+ ...style,
23
+ }}>
24
+ {start !== null && (
25
+ <div style={{ fontSize: '.72rem', color: 'var(--muted, #797b8d)', fontFamily: 'var(--mono, ui-monospace, monospace)' }}>
26
+ {start}–{end} of {total}
27
+ </div>
28
+ )}
29
+ <div style={{ display: 'flex', gap: 4, alignItems: 'center', marginLeft: start !== null ? 'auto' : 0 }}>
30
+ <Button
31
+ variant="ghost"
32
+ size="xs"
33
+ icon={<ChevronLeft size={14} />}
34
+ onClick={() => onChange?.(page - 1)}
35
+ disabled={page <= 1}
36
+ />
37
+ {items.map((item, i) =>
38
+ item === '...'
39
+ ? <span key={`e${i}`} style={{ padding: '0 4px', color: 'var(--muted, #797b8d)', fontSize: '.78rem' }}>…</span>
40
+ : <Button key={item} variant={item === page ? 'primary' : 'no-line'} size="xs" onClick={() => onChange?.(item)} style={{ minWidth: 28 }}>{item}</Button>
41
+ )}
42
+ <Button
43
+ variant="ghost"
44
+ size="xs"
45
+ icon={<ChevronRight size={14} />}
46
+ onClick={() => onChange?.(page + 1)}
47
+ disabled={page >= totalPages}
48
+ />
49
+ </div>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,77 @@
1
+ const VARIANTS = {
2
+ dim: { bg: 'var(--grey-4,#797b8d)', ring: 'var(--grey-4,#797b8d)' },
3
+ ok: { bg: 'var(--success,#27ae60)', ring: 'var(--success,#27ae60)' },
4
+ err: { bg: 'var(--danger,#f64747)', ring: 'var(--danger,#f64747)' },
5
+ warn: { bg: 'var(--accent,#f99e2c)', ring: 'var(--accent,#f99e2c)' },
6
+ blue: { bg: 'var(--blue,#2782e4)', ring: 'var(--blue,#2782e4)' },
7
+ purple: { bg: '#8b5cf6', ring: '#8b5cf6' },
8
+ teal: { bg: '#14b8a6', ring: '#14b8a6' },
9
+ pink: { bg: '#ec4899', ring: '#ec4899' },
10
+ off: { bg: 'var(--t3,#9b9daa)', ring: 'var(--t3,#9b9daa)' },
11
+ };
12
+
13
+ const SIZES = {
14
+ sm: { core: 6, ring: { inset: '-2px', borderWidth: '1.5px' } },
15
+ md: { core: 8, ring: { inset: '-3px', borderWidth: '2px' } },
16
+ lg: { core: 10, ring: { inset: '-4px', borderWidth: '2px' } },
17
+ };
18
+
19
+ const KEYFRAMES = `@keyframes pdot-pulse{0%{transform:scale(1);opacity:.7}100%{transform:scale(1.3);opacity:0}}`;
20
+ let _id = 'cynx-pulsedot-styles';
21
+
22
+ function ensureStyles() {
23
+ if (typeof document === 'undefined') return;
24
+ if (document.getElementById(_id)) return;
25
+ const tag = document.createElement('style');
26
+ tag.id = _id;
27
+ tag.textContent = KEYFRAMES;
28
+ document.head.appendChild(tag);
29
+ }
30
+
31
+ export default function PulseDot({
32
+ variant = 'dim',
33
+ size = 'md',
34
+ pulse = true,
35
+ className = '',
36
+ style = {},
37
+ }) {
38
+ ensureStyles();
39
+
40
+ const v = VARIANTS[variant] || VARIANTS.dim;
41
+ const s = SIZES[size] || SIZES.md;
42
+ const isOff = variant === 'off';
43
+
44
+ return (
45
+ <span
46
+ className={className}
47
+ style={{
48
+ position: 'relative',
49
+ display: 'inline-flex',
50
+ alignItems: 'center',
51
+ justifyContent: 'center',
52
+ width: s.core,
53
+ height: s.core,
54
+ flexShrink: 0,
55
+ ...style,
56
+ }}
57
+ >
58
+ <span style={{
59
+ width: s.core,
60
+ height: s.core,
61
+ borderRadius: '50%',
62
+ background: v.bg,
63
+ }} />
64
+ {!isOff && (
65
+ <span style={{
66
+ position: 'absolute',
67
+ inset: s.ring.inset,
68
+ borderRadius: '50%',
69
+ border: `${s.ring.borderWidth} solid ${v.ring}`,
70
+ opacity: 0,
71
+ animation: `pdot-pulse 1.1s ease-out infinite`,
72
+ animationPlayState: pulse ? 'running' : 'paused',
73
+ }} />
74
+ )}
75
+ </span>
76
+ );
77
+ }