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,415 @@
1
+ import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { ChevronDown, Check, X } from 'lucide-react';
4
+
5
+ const SIZES = {
6
+ xs: { height: 24, fontSize: 11, paddingH: 6, searchHeight: 22, searchFont: 10.5, itemMinHeight: 18, itemPadding: '3px 5px' },
7
+ sm: { height: 28, fontSize: 11.5, paddingH: 8, searchHeight: 26, searchFont: 11.5, itemMinHeight: 22, itemPadding: '4.5px 7px' },
8
+ md: { height: 32, fontSize: 13, paddingH: 10, searchHeight: 32, searchFont: 12.5, itemMinHeight: 28, itemPadding: '8px 10px' },
9
+ lg: { height: 36, fontSize: 14, paddingH: 14, searchHeight: 34, searchFont: 13, itemMinHeight: 32, itemPadding: '9px 12px' },
10
+ };
11
+
12
+ const KEYFRAMES = `
13
+ @keyframes selFadeInDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
14
+ @keyframes selFadeInUp{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
15
+ `;
16
+ let _id = 'cynx-select-styles';
17
+
18
+ function ensureStyles() {
19
+ if (typeof document === 'undefined') return;
20
+ if (document.getElementById(_id)) return;
21
+ const tag = document.createElement('style');
22
+ tag.id = _id;
23
+ tag.textContent = KEYFRAMES;
24
+ document.head.appendChild(tag);
25
+ }
26
+
27
+ export default function Select({
28
+ options = [],
29
+ value,
30
+ onChange,
31
+ placeholder = 'Select...',
32
+ disabled = false,
33
+ multi = false,
34
+ searchable = false,
35
+ clearable = false,
36
+ onClear,
37
+ size = 'md',
38
+ style = {},
39
+ rounded = false,
40
+ showCount = false,
41
+ }) {
42
+ const [open, setOpen] = useState(false);
43
+ const [query, setQuery] = useState('');
44
+ const [pos, setPos] = useState({ top: 0, left: 0, width: 0, openUp: false, maxHeight: 200 });
45
+ const wrapRef = useRef(null);
46
+ const dropRef = useRef(null);
47
+ const inputRef = useRef(null);
48
+
49
+ ensureStyles();
50
+
51
+ const calcPosition = useCallback(() => {
52
+ if (!wrapRef.current) return;
53
+ const r = wrapRef.current.getBoundingClientRect();
54
+ const dropEl = dropRef.current;
55
+ const dropHeight = dropEl ? dropEl.offsetHeight : 220;
56
+ const spaceBelow = window.innerHeight - r.bottom;
57
+ const spaceAbove = r.top;
58
+
59
+ // If not enough space below (< dropHeight + 10) and more space above, open up
60
+ const openUp = spaceBelow < Math.min(dropHeight, 220) + 12 && spaceAbove > spaceBelow;
61
+
62
+ // Dynamic max-height for inner list to prevent overflow
63
+ const availableSpace = openUp ? spaceAbove - 16 : spaceBelow - 16;
64
+ const calculatedMaxHeight = Math.max(90, Math.min(220, availableSpace - (searchable ? 46 : 14)));
65
+
66
+ let top;
67
+ if (openUp) {
68
+ top = Math.max(8, r.top - (dropEl ? dropEl.offsetHeight : dropHeight) - 4);
69
+ } else {
70
+ top = Math.min(window.innerHeight - 40, r.bottom + 4);
71
+ }
72
+
73
+ let left = r.left;
74
+ const width = r.width;
75
+ if (left + width > window.innerWidth - 8) {
76
+ left = Math.max(8, window.innerWidth - width - 8);
77
+ }
78
+
79
+ setPos({
80
+ top,
81
+ left,
82
+ width,
83
+ openUp,
84
+ maxHeight: calculatedMaxHeight,
85
+ });
86
+ }, [searchable]);
87
+
88
+ useEffect(() => {
89
+ if (!open) return;
90
+ function close(e) {
91
+ const inTrigger = wrapRef.current && wrapRef.current.contains(e.target);
92
+ const inDrop = dropRef.current && dropRef.current.contains(e.target);
93
+ if (!inTrigger && !inDrop) setOpen(false);
94
+ }
95
+ document.addEventListener('mousedown', close, true);
96
+ window.addEventListener('scroll', calcPosition, true);
97
+ window.addEventListener('resize', calcPosition);
98
+ return () => {
99
+ document.removeEventListener('mousedown', close, true);
100
+ window.removeEventListener('scroll', calcPosition, true);
101
+ window.removeEventListener('resize', calcPosition);
102
+ };
103
+ }, [open, calcPosition]);
104
+
105
+ useLayoutEffect(() => {
106
+ if (open) {
107
+ calcPosition();
108
+ requestAnimationFrame(calcPosition);
109
+ }
110
+ }, [open, calcPosition]);
111
+
112
+ useEffect(() => {
113
+ if (open && searchable && inputRef.current) inputRef.current.focus();
114
+ }, [open, searchable]);
115
+
116
+ useEffect(() => {
117
+ if (!open) setQuery('');
118
+ }, [open]);
119
+
120
+ const filtered = useMemo(() => {
121
+ if (!query) return options;
122
+ const q = query.toLowerCase().trim();
123
+ return options.filter(o => {
124
+ for (const [key, val] of Object.entries(o)) {
125
+ if (key === 'displayLabel') continue;
126
+ if (val != null && typeof val !== 'function' && typeof val !== 'object') {
127
+ if (String(val).toLowerCase().includes(q)) return true;
128
+ }
129
+ }
130
+ return false;
131
+ });
132
+ }, [options, query]);
133
+
134
+ function handleOpen() {
135
+ if (disabled) return;
136
+ if (!open && wrapRef.current) {
137
+ calcPosition();
138
+ }
139
+ setOpen(o => !o);
140
+ }
141
+
142
+ function toggleOption(optVal) {
143
+ if (multi) {
144
+ const arr = Array.isArray(value) ? value : [];
145
+ const next = arr.includes(optVal) ? arr.filter(v => v !== optVal) : [...arr, optVal];
146
+ onChange(next);
147
+ } else {
148
+ onChange(optVal);
149
+ setOpen(false);
150
+ }
151
+ }
152
+
153
+ function removeTag(e, val) {
154
+ e.stopPropagation();
155
+ onChange((Array.isArray(value) ? value : []).filter(v => v !== val));
156
+ }
157
+
158
+ function isSelected(optVal) {
159
+ return multi ? (Array.isArray(value) && value.includes(optVal)) : value === optVal;
160
+ }
161
+
162
+ function getLabel(optVal) {
163
+ const opt = options.find(o => o.value === optVal);
164
+ return opt ? (opt.displayLabel || opt.label) : optVal;
165
+ }
166
+
167
+ const selectedValues = multi ? (Array.isArray(value) ? value : []) : (value !== undefined && value !== null ? [value] : []);
168
+
169
+ const s = SIZES[size] || SIZES.md;
170
+ const heightVal = s.height;
171
+ const paddingVal = `0 ${s.paddingH}px`;
172
+ const fontSizeVal = `${s.fontSize}px`;
173
+
174
+ let radiusVal = rounded
175
+ ? (typeof rounded === 'string'
176
+ ? (rounded.endsWith('px') || rounded.endsWith('rem') || rounded.startsWith('var') ? rounded : `${rounded}px`)
177
+ : typeof rounded === 'number'
178
+ ? `${rounded}px`
179
+ : '6px')
180
+ : (s.height <= 26 ? '4px' : s.height <= 28 ? '6px' : 'var(--rmd,var(--rads, 8px))');
181
+
182
+ return (
183
+ <div
184
+ ref={wrapRef}
185
+ style={{
186
+ position: 'relative',
187
+ width: '100%',
188
+ boxSizing: 'border-box',
189
+ fontFamily: 'var(--f,var(--sans, inherit))',
190
+ opacity: disabled ? 0.5 : 1,
191
+ pointerEvents: disabled ? 'none' : undefined,
192
+ ...style,
193
+ }}
194
+ >
195
+ <div
196
+ onClick={handleOpen}
197
+ style={{
198
+ width: '100%',
199
+ boxSizing: 'border-box',
200
+ display: 'flex',
201
+ alignItems: 'center',
202
+ justifyContent: 'space-between',
203
+ gap: 4,
204
+ padding: paddingVal,
205
+ height: heightVal,
206
+ background: 'var(--sf,var(--surface, #ffffff))',
207
+ border: `1px solid ${open ? 'var(--accent, #f99e2c)' : 'var(--bd2,var(--border, #e2e8f0))'}`,
208
+ borderRadius: radiusVal,
209
+ cursor: 'pointer',
210
+ fontFamily: 'var(--f,var(--sans,inherit))',
211
+ fontSize: fontSizeVal,
212
+ lineHeight: 1.2,
213
+ color: 'var(--t,var(--primary-text, #0f172a))',
214
+ outline: 'none',
215
+ transition: 'border-color var(--tr,.15s), box-shadow var(--tr,.15s)',
216
+ boxShadow: open ? 'var(--infd, rgba(37,99,235,.1))' : 'var(--shadow,0 1px 3px rgba(20,22,41,.1))',
217
+ }}
218
+ >
219
+ <div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center' }}>
220
+ {multi && selectedValues.length > 0 ? (
221
+ showCount ? (
222
+ <span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: 'var(--t,var(--primary-text, #0f172a))' }}>
223
+ {typeof showCount === 'function'
224
+ ? showCount(selectedValues.length)
225
+ : selectedValues.length === 1
226
+ ? getLabel(selectedValues[0])
227
+ : `Выбрано: ${selectedValues.length}`}
228
+ </span>
229
+ ) : (
230
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, flex: 1, minWidth: 0 }}>
231
+ {selectedValues.map(v => (
232
+ <span key={v} style={{
233
+ display: 'inline-flex',
234
+ alignItems: 'center',
235
+ gap: 4,
236
+ padding: '2px 6px',
237
+ background: 'var(--pd,var(--accent-bg, rgba(249,158,44,.1)))',
238
+ border: '1px solid var(--accent, #f99e2c)',
239
+ borderRadius: 4,
240
+ fontSize: 11,
241
+ fontWeight: 600,
242
+ color: 'var(--accent, #f99e2c)',
243
+ whiteSpace: 'nowrap',
244
+ maxWidth: 140,
245
+ opacity: 0.85,
246
+ }}>
247
+ {getLabel(v)}
248
+ <X
249
+ size={11}
250
+ style={{ cursor: 'pointer', flexShrink: 0, opacity: 0.5 }}
251
+ onMouseDown={(e) => removeTag(e, v)}
252
+ />
253
+ </span>
254
+ ))}
255
+ </div>
256
+ )
257
+ ) : !multi && selectedValues.length > 0 ? (
258
+ <span style={{ fontWeight: 400, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
259
+ {getLabel(selectedValues[0]) || '\u00A0'}
260
+ </span>
261
+ ) : (
262
+ <span style={{ color: 'var(--t3,var(--grey-3, #cbd5e1))', fontWeight: 400 }}>{placeholder}</span>
263
+ )}
264
+ </div>
265
+ {selectedValues.length > 0 && selectedValues[0] !== '' && selectedValues[0] !== null && (clearable || !!onClear) ? (
266
+ <span
267
+ onClick={(e) => {
268
+ e.stopPropagation();
269
+ if (onClear) onClear();
270
+ else onChange?.(multi ? [] : '');
271
+ }}
272
+ title="Сбросить выбор"
273
+ style={{
274
+ display: 'inline-flex',
275
+ alignItems: 'center',
276
+ justifyContent: 'center',
277
+ cursor: 'pointer',
278
+ color: 'var(--muted, #64748b)',
279
+ padding: '0 2px',
280
+ flexShrink: 0,
281
+ borderRadius: 'var(--radxs, 4px)',
282
+ }}
283
+ onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--danger, #ef4444)'; }}
284
+ onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted, #64748b)'; }}
285
+ >
286
+ <X size={12} />
287
+ </span>
288
+ ) : (
289
+ <ChevronDown
290
+ size={12}
291
+ style={{
292
+ flexShrink: 0,
293
+ color: 'var(--t3,var(--grey-3, #cbd5e1))',
294
+ transition: 'transform .2s ease',
295
+ transform: open ? 'rotate(180deg)' : undefined,
296
+ }}
297
+ />
298
+ )}
299
+ </div>
300
+
301
+ {open && createPortal(
302
+ <div
303
+ ref={dropRef}
304
+ style={{
305
+ position: 'fixed',
306
+ zIndex: 9000,
307
+ top: pos.top,
308
+ left: pos.left,
309
+ width: pos.width,
310
+ boxSizing: 'border-box',
311
+ background: 'var(--sf,var(--surface, #ffffff))',
312
+ border: '1px solid var(--bd,var(--border, #e2e8f0))',
313
+ borderRadius: radiusVal,
314
+ boxShadow: 'var(--shadow,0 1px 3px rgba(20,22,41,.1))',
315
+ padding: 3,
316
+ animation: `${pos.openUp ? 'selFadeInUp' : 'selFadeInDown'} .15s ease`,
317
+ overflow: 'hidden',
318
+ }}
319
+ >
320
+ {searchable && (
321
+ <div style={{ padding: 3 }}>
322
+ <input
323
+ ref={inputRef}
324
+ type="text"
325
+ placeholder="Поиск..."
326
+ value={query}
327
+ onChange={e => setQuery(e.target.value)}
328
+ onMouseDown={e => e.stopPropagation()}
329
+ style={{
330
+ width: '100%',
331
+ boxSizing: 'border-box',
332
+ padding: s.height <= 26 ? '2px 5px' : s.height <= 28 ? '3px 6px' : '7px 10px',
333
+ height: s.searchHeight,
334
+ background: 'var(--sf2,var(--grey-1, #f5f5f8))',
335
+ border: '1.5px solid var(--bd2,var(--border, #e2e8f0))',
336
+ borderRadius: s.height <= 26 ? '3px' : s.height <= 28 ? '4px' : 'var(--rmd,var(--rads, 8px))',
337
+ fontFamily: 'var(--f,var(--sans, inherit))',
338
+ fontSize: `${s.searchFont}px`,
339
+ lineHeight: 1,
340
+ color: 'var(--t,var(--primary-text, #0f172a))',
341
+ outline: 'none',
342
+ transition: 'border-color var(--tr,.15s), box-shadow var(--tr,.15s)',
343
+ }}
344
+ />
345
+ </div>
346
+ )}
347
+ <div style={{ maxHeight: pos.maxHeight || 200, overflowY: 'auto' }}>
348
+ {filtered.length === 0 ? (
349
+ <div style={{ padding: '10px 8px', textAlign: 'center', color: 'var(--t3,var(--grey-3, #cbd5e1))', fontSize: 11 }}>
350
+ Ничего не найдено
351
+ </div>
352
+ ) : (
353
+ filtered.map(opt => (
354
+ <div
355
+ key={opt.value}
356
+ onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); if (!opt.disabled) toggleOption(opt.value); }}
357
+ style={{
358
+ display: 'flex',
359
+ alignItems: 'center',
360
+ gap: 6,
361
+ minHeight: s.itemMinHeight,
362
+ padding: s.itemPadding,
363
+ borderRadius: s.height <= 26 ? '3px' : s.height <= 28 ? '4px' : 'var(--rmd,var(--rads, 8px))',
364
+ cursor: opt.disabled ? 'not-allowed' : 'pointer',
365
+ color: isSelected(opt.value) ? 'var(--accent, #f99e2c)' : 'var(--t,var(--primary-text, #0f172a))',
366
+ background: isSelected(opt.value) ? 'var(--pd,var(--accent-bg, rgba(249,158,44,.1)))' : undefined,
367
+ fontSize: `${s.searchFont}px`,
368
+ fontWeight: 400,
369
+ transition: 'background .1s',
370
+ userSelect: 'none',
371
+ opacity: opt.disabled ? 0.4 : 1,
372
+ pointerEvents: opt.disabled ? 'none' : undefined,
373
+ }}
374
+ >
375
+ {multi && (
376
+ <span style={{
377
+ width: 13,
378
+ height: 13,
379
+ border: `1.5px solid ${isSelected(opt.value) ? 'var(--accent, #f99e2c)' : 'var(--bd2,var(--border, #e2e8f0))'}`,
380
+ borderRadius: 3,
381
+ flexShrink: 0,
382
+ position: 'relative',
383
+ transition: 'all .15s',
384
+ background: isSelected(opt.value) ? 'var(--accent, #f99e2c)' : undefined,
385
+ }}>
386
+ {isSelected(opt.value) && (
387
+ <span style={{
388
+ position: 'absolute',
389
+ top: 1,
390
+ left: 3.5,
391
+ width: 4,
392
+ height: 6,
393
+ border: 'solid var(--def-btn-text,#fff)',
394
+ borderWidth: '0 1.5px 1.5px 0',
395
+ transform: 'rotate(45deg)',
396
+ }} />
397
+ )}
398
+ </span>
399
+ )}
400
+ <span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minHeight: '1em' }}>
401
+ {opt.displayLabel || opt.label || '\u00A0'}
402
+ </span>
403
+ {!multi && isSelected(opt.value) && (
404
+ <Check size={12} style={{ flexShrink: 0, color: 'var(--accent, #f99e2c)' }} />
405
+ )}
406
+ </div>
407
+ ))
408
+ )}
409
+ </div>
410
+ </div>,
411
+ document.body
412
+ )}
413
+ </div>
414
+ );
415
+ }
@@ -0,0 +1,118 @@
1
+ const STYLES = `
2
+ @keyframes sk-shimmer{0%{background-position:-600px 0}100%{background-position:600px 0}}
3
+ @keyframes shimmer{0%{background-position:-600px 0}100%{background-position:600px 0}}
4
+ .skeleton{background:linear-gradient(90deg,var(--grey-1,#f0f1f5) 25%,var(--border-light,#e8e8ec) 50%,var(--grey-1,#f0f1f5) 75%);background-size:600px 100%;animation:sk-shimmer 1.4s ease infinite;border-radius:var(--radxs,4px)}
5
+ .skeleton-text{height:12px;margin-bottom:6px}
6
+ `;
7
+ let _id = 'cynx-skeleton-styles';
8
+ function ensureStyles() {
9
+ if (typeof document === 'undefined') return;
10
+ if (document.getElementById(_id)) return;
11
+ const tag = document.createElement('style');
12
+ tag.id = _id;
13
+ tag.textContent = STYLES;
14
+ document.head.appendChild(tag);
15
+ }
16
+
17
+ // Auto-inject on module load so raw className="skeleton" works everywhere
18
+ if (typeof document !== 'undefined') ensureStyles();
19
+
20
+ const SK_BASE = {
21
+ background: 'linear-gradient(90deg, var(--grey-1, #f0f1f5) 25%, var(--border-light, #e8e8ec) 50%, var(--grey-1, #f0f1f5) 75%)',
22
+ backgroundSize: '600px 100%',
23
+ animation: 'sk-shimmer 1.4s ease infinite',
24
+ borderRadius: 'var(--radxs, 4px)',
25
+ flexShrink: 0,
26
+ };
27
+
28
+ export function Skeleton({ width = '100%', height = 12, circle, size, radius, style = {}, className = '' }) {
29
+ ensureStyles();
30
+ const w = circle ? size : width;
31
+ const h = circle ? size : height;
32
+ return (
33
+ <div
34
+ className={className}
35
+ style={{
36
+ ...SK_BASE,
37
+ width: w,
38
+ height: h,
39
+ borderRadius: circle ? '50%' : radius,
40
+ ...style,
41
+ }}
42
+ />
43
+ );
44
+ }
45
+
46
+ export function SkeletonText({ lines = 3, widths, style = {} }) {
47
+ ensureStyles();
48
+ const defaultWidths = ['100%', '90%', '65%', '80%', '50%'];
49
+ const ws = widths || defaultWidths;
50
+ return (
51
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
52
+ {Array.from({ length: lines }, (_, i) => (
53
+ <div
54
+ key={i}
55
+ style={{
56
+ ...SK_BASE,
57
+ width: ws[i % ws.length],
58
+ height: 12,
59
+ }}
60
+ />
61
+ ))}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ export function SkeletonCard({ rows = 3, style = {} }) {
67
+ ensureStyles();
68
+ return (
69
+ <div style={{
70
+ background: 'var(--surface, #fff)',
71
+ border: '1px solid var(--border-light, #ebebf0)',
72
+ borderRadius: 'var(--rad, 10px)',
73
+ padding: '14px 16px',
74
+ boxShadow: 'var(--shadow, 0 1px 3px rgba(0,0,0,.04))',
75
+ ...style,
76
+ }}>
77
+ <div style={{
78
+ display: 'flex',
79
+ alignItems: 'center',
80
+ justifyContent: 'space-between',
81
+ marginBottom: 12,
82
+ }}>
83
+ <Skeleton width={140} height={14} />
84
+ <Skeleton width={70} height={26} radius="var(--rads, 6px)" />
85
+ </div>
86
+ <SkeletonText lines={rows} />
87
+ </div>
88
+ );
89
+ }
90
+
91
+ export default Skeleton;
92
+
93
+ export function SkeletonRows({ cols = 5, rows = 5 }) {
94
+ return Array.from({ length: rows }).map((_, i) => (
95
+ <tr key={i}>
96
+ {Array.from({ length: cols }).map((_, j) => (
97
+ <td key={j}>
98
+ <div style={{ ...SK_BASE, width: `${50 + Math.random() * 40}%`, height: 12 }} />
99
+ </td>
100
+ ))}
101
+ </tr>
102
+ ));
103
+ }
104
+
105
+ export function SkeletonStats({ count = 4 }) {
106
+ ensureStyles();
107
+ return (
108
+ <div style={{ display: 'flex', gap: 16 }}>
109
+ {Array.from({ length: count }).map((_, i) => (
110
+ <div key={i} style={{ flex: 1 }}>
111
+ <Skeleton width="40%" height={10} style={{ marginBottom: 10 }} />
112
+ <Skeleton width="50%" height={28} style={{ marginBottom: 8 }} />
113
+ <Skeleton width="30%" height={10} />
114
+ </div>
115
+ ))}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,108 @@
1
+ const STYLES = `.spin{animation:cx-spin .7s linear infinite}@keyframes cx-spin{to{transform:rotate(360deg)}}.v-progress-circular--indeterminate>svg{animation:progress-circular-rotate .8s linear infinite;transform-origin:center center;transition:all .2s ease-in-out}.v-progress-circular>svg{width:100%;height:100%;margin:auto;position:absolute;inset:0;z-index:0}@keyframes progress-circular-rotate{100%{transform:rotate(270deg)}}@keyframes progress-circular-dash{0%{stroke-dasharray:1,200;stroke-dashoffset:0}50%{stroke-dasharray:89,200;stroke-dashoffset:-35px}100%{stroke-dasharray:89,200;stroke-dashoffset:-124px}}.v-progress-circular--indeterminate .v-progress-circular__overlay{animation:progress-circular-dash 1.4s ease-in-out infinite;stroke-linecap:round}`;
2
+
3
+ let _id = 'cynx-spinner-styles';
4
+ function ensureStyles() {
5
+ if (typeof document === 'undefined') return;
6
+ if (document.getElementById(_id)) return;
7
+ const tag = document.createElement('style');
8
+ tag.id = _id;
9
+ tag.textContent = STYLES;
10
+ document.head.appendChild(tag);
11
+ }
12
+
13
+ // Auto-inject on module load
14
+ if (typeof document !== 'undefined') ensureStyles();
15
+
16
+ export function SpinnerClassic({ size = 20, inline = false, color = 'currentColor', fullPage = false }) {
17
+ ensureStyles();
18
+ const s = inline ? 14 : size;
19
+ const svg = (
20
+ <svg className="spin" width={s} height={s} viewBox="0 0 24 24" fill="none"
21
+ stroke={color} strokeWidth="2.5" strokeLinecap="round">
22
+ <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
23
+ </svg>
24
+ );
25
+
26
+ if (fullPage) {
27
+ return (
28
+ <div style={{
29
+ position: 'fixed', inset: 0,
30
+ background: 'var(--bg, #f8fafc)',
31
+ display: 'flex', flexDirection: 'column',
32
+ alignItems: 'center', justifyContent: 'center', gap: 16,
33
+ zIndex: 9999,
34
+ }}>
35
+ {svg}
36
+ <div style={{ fontSize: '.75rem', fontFamily: 'var(--mono, ui-monospace, monospace)', color: 'var(--muted, #797b8d)' }}>
37
+ Loading...
38
+ </div>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ if (inline) return svg;
44
+
45
+ return (
46
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 0' }}>
47
+ {svg}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ export function Spinner({ size = 20, inline = false, color = 'currentColor', fullPage = false }) {
53
+ ensureStyles();
54
+ const s = inline ? 14 : size;
55
+
56
+ const svg = (
57
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42.42 42.42"
58
+ style={{ width: s, height: s, transform: 'rotate(-90deg)' }}>
59
+ <circle fill="transparent" cx="50%" cy="50%" r="20"
60
+ stroke="var(--grey-2, #e0e0e0)" strokeWidth="2.42" strokeDasharray="125.66" strokeDashoffset="0" />
61
+ <circle className="v-progress-circular__overlay" fill="transparent" cx="50%" cy="50%" r="20"
62
+ stroke={color} strokeWidth="2.42" strokeDasharray="125.66" strokeDashoffset="125.66" />
63
+ </svg>
64
+ );
65
+
66
+ if (fullPage) {
67
+ return (
68
+ <div style={{
69
+ position: 'fixed', inset: 0,
70
+ background: 'var(--bg, #f8fafc)',
71
+ display: 'flex', flexDirection: 'column',
72
+ alignItems: 'center', justifyContent: 'center', gap: 16,
73
+ zIndex: 9999,
74
+ }}>
75
+ <div className="v-progress-circular v-progress-circular--indeterminate" style={{ width: s, height: s }}>
76
+ {svg}
77
+ </div>
78
+ <div style={{ fontSize: '.75rem', fontFamily: 'var(--mono, ui-monospace, monospace)', color: 'var(--muted, #797b8d)' }}>
79
+ Loading...
80
+ </div>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ if (inline) {
86
+ return (
87
+ <div className="v-progress-circular v-progress-circular--indeterminate"
88
+ style={{ width: s, height: s, position: 'relative', display: 'inline-flex', flexShrink: 0 }}>
89
+ <svg viewBox="0 0 42.42 42.42"
90
+ style={{ width: '100%', height: '100%', transform: 'rotate(-90deg)' }}>
91
+ <circle className="v-progress-circular__overlay" fill="transparent" cx="50%" cy="50%" r="20"
92
+ stroke={color} strokeWidth="2.42" strokeDasharray="125.66" strokeDashoffset="125.66" />
93
+ </svg>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ return (
99
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 0' }}>
100
+ <div className="v-progress-circular v-progress-circular--indeterminate" style={{ width: s, height: s }}>
101
+ {svg}
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ export { Spinner as SpinnerNew };
108
+ export default Spinner;
@@ -0,0 +1,25 @@
1
+ import LineProgress from './LineProgress';
2
+
3
+ export default function StatBarCard({ title, icon, items, style }) {
4
+ const total = items.reduce((s, i) => s + (i.value || 0), 0);
5
+
6
+ return (
7
+ <div style={{
8
+ background: 'var(--surface,#fff)', border: '1px solid var(--border, #e2e8f0)', borderRadius: 'var(--rad, 10px)',
9
+ boxShadow: 'var(--shadow, 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04))',
10
+ padding: '12px 14px', minWidth: 220, ...style,
11
+ }}>
12
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
13
+ <div style={{ fontSize: '.58rem', fontWeight: 700, color: 'var(--muted, #9ca3af)', textTransform: 'uppercase', letterSpacing: '.08em' }}>
14
+ {title}{total ? ` (${total})` : ''}
15
+ </div>
16
+ {icon && <span style={{ color: 'var(--muted, #9ca3af)' }}>{icon}</span>}
17
+ </div>
18
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
19
+ {items.map((item, i) => (
20
+ <LineProgress key={i} value={item.value} total={total} color={item.color} label={item.label} size="sm" />
21
+ ))}
22
+ </div>
23
+ </div>
24
+ );
25
+ }