cynx-ui 1.0.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/ActionsMenu.jsx +149 -0
- package/ActivityCard.jsx +184 -0
- package/AuthOverlay.jsx +94 -0
- package/Badge.jsx +105 -0
- package/BrandingCard.jsx +145 -0
- package/Button.jsx +76 -0
- package/Card.jsx +62 -0
- package/Checkbox.jsx +59 -0
- package/ChipSelect.jsx +54 -0
- package/CircularProgress.jsx +163 -0
- package/Confirm.jsx +79 -0
- package/DatePicker.jsx +262 -0
- package/DeleteConfirmModal.jsx +119 -0
- package/Dropdown.jsx +108 -0
- package/Dropzone.jsx +29 -0
- package/HoverCard.jsx +81 -0
- package/ImageUpload.jsx +147 -0
- package/Input.jsx +121 -0
- package/Layout.jsx +128 -0
- package/LineProgress.jsx +109 -0
- package/Modal.jsx +98 -0
- package/MultiSelect.jsx +160 -0
- package/Notifications.jsx +118 -0
- package/Pagination.jsx +41 -0
- package/PulseDot.jsx +58 -0
- package/SearchableDropdown.jsx +109 -0
- package/Select.jsx +218 -0
- package/Skeleton.jsx +126 -0
- package/Spinner.jsx +36 -0
- package/StatBarCard.jsx +25 -0
- package/StatsBar.jsx +88 -0
- package/StatsCard.jsx +97 -0
- package/TabBar.jsx +92 -0
- package/Tabs.jsx +16 -0
- package/Toast.jsx +57 -0
- package/Toggle.jsx +58 -0
- package/UptimeBar.jsx +200 -0
- package/XTable.jsx +22 -0
- package/index.js +45 -0
- package/package.json +19 -0
package/Select.jsx
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { ChevronDown, Check, X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
const STYLES = `
|
|
6
|
+
.sel-wrap{position:relative;width:100%;font-family:var(--f,var(--sans));font-size:var(--fsz,13px)}
|
|
7
|
+
.sel-wrap.disabled{opacity:.5;pointer-events:none}
|
|
8
|
+
.sel-trigger{
|
|
9
|
+
width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;
|
|
10
|
+
padding:8px 11px;height:35px;
|
|
11
|
+
background:var(--sf,var(--surface));border:1.5px solid var(--bd2,var(--border));
|
|
12
|
+
border-radius:var(--rmd,var(--rads));cursor:pointer;
|
|
13
|
+
font-family:var(--f,var(--sans,inherit));font-size:12.5px;line-height:1;
|
|
14
|
+
color:var(--t,var(--primary-text));outline:none;
|
|
15
|
+
transition:border-color var(--tr,.15s),box-shadow var(--tr,.15s);
|
|
16
|
+
box-shadow:var(--shadow,0 1px 3px rgba(20,22,41,.1))
|
|
17
|
+
}
|
|
18
|
+
.sel-trigger:hover{border-color:var(--bd2,var(--grey-2))}
|
|
19
|
+
.sel-trigger.open{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
|
|
20
|
+
.sel-trigger-inner{flex:1;min-width:0;display:flex;align-items:center}
|
|
21
|
+
.sel-value{font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
22
|
+
.sel-placeholder{color:var(--t3,var(--grey-3));font-weight:400}
|
|
23
|
+
.sel-chevron{flex-shrink:0;color:var(--t3,var(--grey-3));transition:transform .2s ease}
|
|
24
|
+
.sel-trigger.open .sel-chevron{transform:rotate(180deg)}
|
|
25
|
+
.sel-tags{display:flex;flex-wrap:wrap;gap:4px;flex:1;min-width:0}
|
|
26
|
+
.sel-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 6px;background:var(--pd,var(--accent-bg));border:1px solid var(--accent);border-radius:4px;font-size:11px;font-weight:600;color:var(--accent);white-space:nowrap;max-width:140px;opacity:.85}
|
|
27
|
+
.sel-tag-x{cursor:pointer;flex-shrink:0;opacity:.5;transition:opacity .15s}
|
|
28
|
+
.sel-tag-x:hover{opacity:1}
|
|
29
|
+
.sel-dropdown{
|
|
30
|
+
position:fixed;z-index:9000;
|
|
31
|
+
background:var(--sf,var(--surface));border:1px solid var(--bd,var(--border));
|
|
32
|
+
border-radius:var(--rmd,var(--rads));box-shadow:var(--shadow,0 1px 3px rgba(20,22,41,.1));
|
|
33
|
+
padding:4px;animation:selFadeIn .15s ease;overflow:hidden
|
|
34
|
+
}
|
|
35
|
+
@keyframes selFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
|
|
36
|
+
.sel-search-wrap{padding:4px}
|
|
37
|
+
.sel-search{
|
|
38
|
+
width:100%;padding:7px 10px;height:35px;
|
|
39
|
+
background:var(--sf2,var(--grey-1));border:1.5px solid var(--bd2,var(--border));
|
|
40
|
+
border-radius:var(--rmd,var(--rads));font-family:var(--f,var(--sans));font-size:12.5px;line-height:1;
|
|
41
|
+
color:var(--t,var(--primary-text));outline:none;
|
|
42
|
+
transition:border-color var(--tr,.15s),box-shadow var(--tr,.15s)
|
|
43
|
+
}
|
|
44
|
+
.sel-search::placeholder{color:var(--t3,var(--grey-3))}
|
|
45
|
+
.sel-search:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
|
|
46
|
+
.sel-options{max-height:200px;overflow-y:auto}
|
|
47
|
+
.sel-options::-webkit-scrollbar{width:4px}
|
|
48
|
+
.sel-options::-webkit-scrollbar-thumb{background:var(--bd2,var(--grey-2));border-radius:99px}
|
|
49
|
+
.sel-option{
|
|
50
|
+
display:flex;align-items:center;gap:8px;padding:8px 10px;
|
|
51
|
+
border-radius:var(--rmd,var(--rads));cursor:pointer;
|
|
52
|
+
color:var(--t,var(--primary-text));font-size:12.5px;
|
|
53
|
+
font-weight:400 !important;transition:background .1s;user-select:none
|
|
54
|
+
}
|
|
55
|
+
.sel-option:hover{background:var(--sf2,var(--hover-bg))}
|
|
56
|
+
.sel-option.selected{color:var(--accent);background:var(--pd,var(--accent-bg))}
|
|
57
|
+
.sel-option.disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
|
|
58
|
+
.sel-opt-label{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
59
|
+
.sel-check{flex-shrink:0;color:var(--accent)}
|
|
60
|
+
.sel-check-box{width:15px;height:15px;border:1.5px solid var(--bd2,var(--border));border-radius:3px;flex-shrink:0;position:relative;transition:all .15s}
|
|
61
|
+
.sel-check-box.checked{background:var(--accent);border-color:var(--accent)}
|
|
62
|
+
.sel-check-box.checked::after{content:'';position:absolute;top:1px;left:3.5px;width:4.5px;height:7px;border:solid #fff;border-width:0 1.5px 1.5px 0;transform:rotate(45deg)}
|
|
63
|
+
.sel-empty{padding:14px 10px;text-align:center;color:var(--t3,var(--grey-3));font-size:11px}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
let styleInjected = false;
|
|
67
|
+
|
|
68
|
+
export default function Select({
|
|
69
|
+
options = [],
|
|
70
|
+
value,
|
|
71
|
+
onChange,
|
|
72
|
+
placeholder = 'Select...',
|
|
73
|
+
disabled = false,
|
|
74
|
+
multi = false,
|
|
75
|
+
searchable = false,
|
|
76
|
+
style = {},
|
|
77
|
+
}) {
|
|
78
|
+
const [open, setOpen] = useState(false);
|
|
79
|
+
const [query, setQuery] = useState('');
|
|
80
|
+
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
|
|
81
|
+
const wrapRef = useRef(null);
|
|
82
|
+
const dropRef = useRef(null);
|
|
83
|
+
const inputRef = useRef(null);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!styleInjected) {
|
|
87
|
+
const tag = document.createElement('style');
|
|
88
|
+
tag.textContent = STYLES;
|
|
89
|
+
document.head.appendChild(tag);
|
|
90
|
+
styleInjected = true;
|
|
91
|
+
}
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!open) return;
|
|
96
|
+
function updatePos() {
|
|
97
|
+
if (wrapRef.current) {
|
|
98
|
+
const r = wrapRef.current.getBoundingClientRect();
|
|
99
|
+
setPos({ top: r.bottom + 4, left: r.left, width: r.width });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function close(e) {
|
|
103
|
+
const inTrigger = wrapRef.current && wrapRef.current.contains(e.target);
|
|
104
|
+
const inDrop = dropRef.current && dropRef.current.contains(e.target);
|
|
105
|
+
if (!inTrigger && !inDrop) setOpen(false);
|
|
106
|
+
}
|
|
107
|
+
document.addEventListener('mousedown', close, true);
|
|
108
|
+
window.addEventListener('scroll', updatePos, true);
|
|
109
|
+
window.addEventListener('resize', updatePos);
|
|
110
|
+
return () => {
|
|
111
|
+
document.removeEventListener('mousedown', close, true);
|
|
112
|
+
window.removeEventListener('scroll', updatePos, true);
|
|
113
|
+
window.removeEventListener('resize', updatePos);
|
|
114
|
+
};
|
|
115
|
+
}, [open]);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (open && searchable && inputRef.current) inputRef.current.focus();
|
|
119
|
+
}, [open, searchable]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!open) setQuery('');
|
|
123
|
+
}, [open]);
|
|
124
|
+
|
|
125
|
+
const filtered = useMemo(() => {
|
|
126
|
+
if (!query) return options;
|
|
127
|
+
const q = query.toLowerCase();
|
|
128
|
+
return options.filter(o => o.label.toLowerCase().includes(q));
|
|
129
|
+
}, [options, query]);
|
|
130
|
+
|
|
131
|
+
function handleOpen() {
|
|
132
|
+
if (disabled) return;
|
|
133
|
+
if (!open && wrapRef.current) {
|
|
134
|
+
const r = wrapRef.current.getBoundingClientRect();
|
|
135
|
+
setPos({ top: r.bottom + 4, left: r.left, width: r.width });
|
|
136
|
+
}
|
|
137
|
+
setOpen(o => !o);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function toggleOption(optVal) {
|
|
141
|
+
if (multi) {
|
|
142
|
+
const arr = Array.isArray(value) ? value : [];
|
|
143
|
+
const next = arr.includes(optVal) ? arr.filter(v => v !== optVal) : [...arr, optVal];
|
|
144
|
+
onChange(next);
|
|
145
|
+
} else {
|
|
146
|
+
onChange(optVal);
|
|
147
|
+
setOpen(false);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function removeTag(e, val) {
|
|
152
|
+
e.stopPropagation();
|
|
153
|
+
onChange((Array.isArray(value) ? value : []).filter(v => v !== val));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isSelected(optVal) {
|
|
157
|
+
return multi ? (Array.isArray(value) && value.includes(optVal)) : value === optVal;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getLabel(optVal) {
|
|
161
|
+
const opt = options.find(o => o.value === optVal);
|
|
162
|
+
return opt ? opt.label : optVal;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const selectedValues = multi ? (Array.isArray(value) ? value : []) : (value ? [value] : []);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className={`sel-wrap${disabled ? ' disabled' : ''}${multi ? ' multi' : ''}`} ref={wrapRef} style={style}>
|
|
169
|
+
<div className={`sel-trigger${open ? ' open' : ''}`} onClick={handleOpen}>
|
|
170
|
+
<div className="sel-trigger-inner">
|
|
171
|
+
{multi && selectedValues.length > 0 ? (
|
|
172
|
+
<div className="sel-tags">
|
|
173
|
+
{selectedValues.map(v => (
|
|
174
|
+
<span key={v} className="sel-tag">
|
|
175
|
+
{getLabel(v)}
|
|
176
|
+
<X size={11} className="sel-tag-x" onMouseDown={(e) => removeTag(e, v)} />
|
|
177
|
+
</span>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
) : !multi && selectedValues.length > 0 ? (
|
|
181
|
+
<span className="sel-value">{getLabel(selectedValues[0])}</span>
|
|
182
|
+
) : (
|
|
183
|
+
<span className="sel-placeholder">{placeholder}</span>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
<ChevronDown size={13} className="sel-chevron" />
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{open && createPortal(
|
|
190
|
+
<div ref={dropRef} className="sel-dropdown" style={{ top: pos.top, left: pos.left, width: pos.width }}>
|
|
191
|
+
{searchable && (
|
|
192
|
+
<div className="sel-search-wrap">
|
|
193
|
+
<input ref={inputRef} className="sel-search" type="text" placeholder="Search..."
|
|
194
|
+
value={query} onChange={e => setQuery(e.target.value)}
|
|
195
|
+
onMouseDown={e => e.stopPropagation()} />
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
<div className="sel-options">
|
|
199
|
+
{filtered.length === 0 ? (
|
|
200
|
+
<div className="sel-empty">No options found</div>
|
|
201
|
+
) : (
|
|
202
|
+
filtered.map(opt => (
|
|
203
|
+
<div key={opt.value}
|
|
204
|
+
className={`sel-option${isSelected(opt.value) ? ' selected' : ''}${opt.disabled ? ' disabled' : ''}`}
|
|
205
|
+
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); if (!opt.disabled) toggleOption(opt.value); }}>
|
|
206
|
+
{multi && <span className={`sel-check-box${isSelected(opt.value) ? ' checked' : ''}`} />}
|
|
207
|
+
<span className="sel-opt-label">{opt.label}</span>
|
|
208
|
+
{!multi && isSelected(opt.value) && <Check size={13} className="sel-check" />}
|
|
209
|
+
</div>
|
|
210
|
+
))
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
</div>,
|
|
214
|
+
document.body
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
package/Skeleton.jsx
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable skeleton loading component.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* <Skeleton /> — single block (default 100% x 12px)
|
|
6
|
+
* <Skeleton width={80} height={12} /> — custom size
|
|
7
|
+
* <Skeleton circle size={32} /> — circle (avatar, icon)
|
|
8
|
+
* <SkeletonText lines={3} /> — multiple text lines
|
|
9
|
+
* <SkeletonCard /> — card-shaped skeleton
|
|
10
|
+
* <SkeletonCard rows={4} /> — card with more rows
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const STYLES = `
|
|
14
|
+
@keyframes sk-shimmer {
|
|
15
|
+
0% { background-position: -600px 0; }
|
|
16
|
+
100% { background-position: 600px 0; }
|
|
17
|
+
}
|
|
18
|
+
.sk {
|
|
19
|
+
background: linear-gradient(90deg, var(--grey-1, #f0f1f5) 25%, var(--border-light, #e8e8ec) 50%, var(--grey-1, #f0f1f5) 75%);
|
|
20
|
+
background-size: 600px 100%;
|
|
21
|
+
animation: sk-shimmer 1.4s ease infinite;
|
|
22
|
+
border-radius: var(--radxs, 4px);
|
|
23
|
+
flex-shrink: 0;
|
|
24
|
+
}
|
|
25
|
+
.sk-card {
|
|
26
|
+
background: var(--surface, #fff);
|
|
27
|
+
border: 1px solid var(--border-light, #ebebf0);
|
|
28
|
+
border-radius: var(--rad, 10px);
|
|
29
|
+
padding: 14px 16px;
|
|
30
|
+
box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
|
31
|
+
}
|
|
32
|
+
.sk-card-head {
|
|
33
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
34
|
+
margin-bottom: 12px;
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
let injected = false;
|
|
39
|
+
|
|
40
|
+
function ensureStyles() {
|
|
41
|
+
if (injected || typeof document === 'undefined') return;
|
|
42
|
+
const tag = document.createElement('style');
|
|
43
|
+
tag.textContent = STYLES;
|
|
44
|
+
document.head.appendChild(tag);
|
|
45
|
+
injected = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function Skeleton({ width = '100%', height = 12, circle, size, radius, style = {}, className = '' }) {
|
|
49
|
+
ensureStyles();
|
|
50
|
+
const w = circle ? size : width;
|
|
51
|
+
const h = circle ? size : height;
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
className={`sk ${className}`}
|
|
55
|
+
style={{
|
|
56
|
+
width: w,
|
|
57
|
+
height: h,
|
|
58
|
+
borderRadius: circle ? '50%' : radius,
|
|
59
|
+
...style,
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function SkeletonText({ lines = 3, widths, style = {} }) {
|
|
66
|
+
ensureStyles();
|
|
67
|
+
const defaultWidths = ['100%', '90%', '65%', '80%', '50%'];
|
|
68
|
+
const ws = widths || defaultWidths;
|
|
69
|
+
return (
|
|
70
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
|
|
71
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
72
|
+
<div
|
|
73
|
+
key={i}
|
|
74
|
+
className="sk"
|
|
75
|
+
style={{
|
|
76
|
+
width: ws[i % ws.length],
|
|
77
|
+
height: 12,
|
|
78
|
+
borderRadius: 'var(--radxs, 4px)',
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function SkeletonCard({ rows = 3, style = {} }) {
|
|
87
|
+
ensureStyles();
|
|
88
|
+
return (
|
|
89
|
+
<div className="sk-card" style={style}>
|
|
90
|
+
<div className="sk-card-head">
|
|
91
|
+
<Skeleton width={140} height={14} />
|
|
92
|
+
<Skeleton width={70} height={26} radius="var(--rads, 6px)" />
|
|
93
|
+
</div>
|
|
94
|
+
<SkeletonText lines={rows} />
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default Skeleton;
|
|
100
|
+
|
|
101
|
+
export function SkeletonRows({ cols = 5, rows = 5 }) {
|
|
102
|
+
return Array.from({ length: rows }).map((_, i) => (
|
|
103
|
+
<tr key={i}>
|
|
104
|
+
{Array.from({ length: cols }).map((_, j) => (
|
|
105
|
+
<td key={j}>
|
|
106
|
+
<div className="sk" style={{ width: `${50 + Math.random() * 40}%`, height: 12 }} />
|
|
107
|
+
</td>
|
|
108
|
+
))}
|
|
109
|
+
</tr>
|
|
110
|
+
));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function SkeletonStats({ count = 4 }) {
|
|
114
|
+
ensureStyles();
|
|
115
|
+
return (
|
|
116
|
+
<div style={{ display: 'flex', gap: 16 }}>
|
|
117
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
118
|
+
<div key={i} style={{ flex: 1 }}>
|
|
119
|
+
<Skeleton width="40%" height={10} style={{ marginBottom: 10 }} />
|
|
120
|
+
<Skeleton width="50%" height={28} style={{ marginBottom: 8 }} />
|
|
121
|
+
<Skeleton width="30%" height={10} />
|
|
122
|
+
</div>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
package/Spinner.jsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function Spinner({ size = 20, inline = false, color = 'currentColor', fullPage = false }) {
|
|
2
|
+
const s = inline ? 14 : size;
|
|
3
|
+
const svg = (
|
|
4
|
+
<svg className="spin" width={s} height={s} viewBox="0 0 24 24" fill="none"
|
|
5
|
+
stroke={color} strokeWidth="2.5" strokeLinecap="round">
|
|
6
|
+
<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"/>
|
|
7
|
+
</svg>
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
if (fullPage) {
|
|
11
|
+
return (
|
|
12
|
+
<div style={{
|
|
13
|
+
position: 'fixed', inset: 0,
|
|
14
|
+
background: 'var(--bg)',
|
|
15
|
+
display: 'flex', flexDirection: 'column',
|
|
16
|
+
alignItems: 'center', justifyContent: 'center', gap: 16,
|
|
17
|
+
zIndex: 9999,
|
|
18
|
+
}}>
|
|
19
|
+
{svg}
|
|
20
|
+
<div style={{ fontSize: '.75rem', fontFamily: 'var(--mono)', color: 'var(--muted)' }}>
|
|
21
|
+
Loading...
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (inline) return svg;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 0' }}>
|
|
31
|
+
{svg}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default Spinner;
|
package/StatBarCard.jsx
ADDED
|
@@ -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: '#fff', border: '1px solid var(--border)', borderRadius: 'var(--rad)',
|
|
9
|
+
boxShadow: '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
|
+
}
|
package/StatsBar.jsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Users, Monitor, ShieldAlert, Activity, WifiOff } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
const itemStyle = {
|
|
4
|
+
display: 'inline-flex',
|
|
5
|
+
alignItems: 'center',
|
|
6
|
+
gap: 5,
|
|
7
|
+
padding: '4px 8px',
|
|
8
|
+
fontSize: '.8rem',
|
|
9
|
+
fontWeight: 600,
|
|
10
|
+
color: 'var(--t)',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const iconWrap = (color) => ({
|
|
14
|
+
display: 'inline-flex',
|
|
15
|
+
alignItems: 'center',
|
|
16
|
+
justifyContent: 'center',
|
|
17
|
+
width: 20,
|
|
18
|
+
height: 20,
|
|
19
|
+
borderRadius: 5,
|
|
20
|
+
background: color + '18',
|
|
21
|
+
color: color,
|
|
22
|
+
flexShrink: 0,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const sep = {
|
|
26
|
+
width: 1,
|
|
27
|
+
height: 14,
|
|
28
|
+
background: 'var(--border-light)',
|
|
29
|
+
margin: '0 3px',
|
|
30
|
+
flexShrink: 0,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const groupStyle = {
|
|
34
|
+
display: 'inline-flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
gap: 0,
|
|
37
|
+
padding: '3px 8px',
|
|
38
|
+
minHeight: 32,
|
|
39
|
+
borderRadius: 7,
|
|
40
|
+
border: '1px solid var(--border)',
|
|
41
|
+
background: 'var(--surface)',
|
|
42
|
+
boxShadow: '0 1px 2px rgba(0,0,0,.05)',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default function StatsBar({ total = 0, active = 0, blocked = 0, actions = 0, type }) {
|
|
46
|
+
const dotted = type === 'dotted';
|
|
47
|
+
const dot = (color) => dotted ? <span style={{ color, fontSize: '.8rem', fontWeight: 700 }}>•</span> : null;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
51
|
+
{/* Device group */}
|
|
52
|
+
<div style={groupStyle}>
|
|
53
|
+
<div style={itemStyle}>
|
|
54
|
+
<span style={iconWrap('#6366f1')}><Monitor size={10} /></span>
|
|
55
|
+
{dot('#6366f1')}
|
|
56
|
+
<span>{total}</span>
|
|
57
|
+
</div>
|
|
58
|
+
<div style={sep} />
|
|
59
|
+
<div style={itemStyle}>
|
|
60
|
+
<span style={iconWrap('#22c55e')}><Users size={10} /></span>
|
|
61
|
+
{dot('#22c55e')}
|
|
62
|
+
<span>{active}</span>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Status group */}
|
|
67
|
+
<div style={groupStyle}>
|
|
68
|
+
<div style={itemStyle}>
|
|
69
|
+
<span style={iconWrap('#ef4444')}><ShieldAlert size={10} /></span>
|
|
70
|
+
{dot('#ef4444')}
|
|
71
|
+
<span>{blocked}</span>
|
|
72
|
+
</div>
|
|
73
|
+
<div style={sep} />
|
|
74
|
+
<div style={itemStyle}>
|
|
75
|
+
<span style={iconWrap('#f59e0b')}><Activity size={10} /></span>
|
|
76
|
+
{dot('#f59e0b')}
|
|
77
|
+
<span>{actions}</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div style={sep} />
|
|
80
|
+
<div style={itemStyle}>
|
|
81
|
+
<span style={iconWrap('#94a3b8')}><WifiOff size={10} /></span>
|
|
82
|
+
{dot('#94a3b8')}
|
|
83
|
+
<span>{total - active}</span>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
package/StatsCard.jsx
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import CircularProgress from './CircularProgress';
|
|
2
|
+
|
|
3
|
+
const STYLES = `
|
|
4
|
+
.scard{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;min-height:68px;border-radius:var(--rad,10px);background:var(--surface,#fff);border:1px solid var(--border);box-shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);min-width:0;transition:border-color .15s}
|
|
5
|
+
.scard:hover{border-color:var(--border,#d7d8e0)}
|
|
6
|
+
.scard-left{display:flex;flex-direction:column;min-width:0;flex:1}
|
|
7
|
+
.scard-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
|
|
8
|
+
.scard-head-left{display:flex;align-items:center;gap:6px}
|
|
9
|
+
.scard-icon{width:24px;height:24px;border-radius:6px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
|
|
10
|
+
.scard-icon svg{width:12px;height:12px}
|
|
11
|
+
.scard-title{font-size:.58rem;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--muted,#9b9daa)}
|
|
12
|
+
.scard-value{font-size:1.1rem;font-weight:800;letter-spacing:-.02em;color:var(--primary-text,#0f111c);line-height:1.2;min-height:1.32em}
|
|
13
|
+
.scard-sub{font-size:.62rem;color:var(--muted,#9b9daa);font-weight:500;margin-top:1px;min-height:1em}
|
|
14
|
+
.scard-right{display:flex;align-items:center;gap:8px;flex-shrink:0}
|
|
15
|
+
.scard-simple{padding:12px 14px;flex-direction:column;align-items:flex-start;justify-content:flex-start}
|
|
16
|
+
.scard-simple .scard-head{width:100%;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between}
|
|
17
|
+
.scard-simple .scard-icon-simple{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
|
|
18
|
+
.scard-simple .scard-icon-simple svg{width:14px;height:14px}
|
|
19
|
+
.scard-simple .scard-value{font-size:1.25rem;font-weight:800;letter-spacing:-.03em;line-height:1.1;min-height:1.375em}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
let styleInjected = false;
|
|
23
|
+
|
|
24
|
+
export default function StatsCard({
|
|
25
|
+
variant = 'default',
|
|
26
|
+
icon,
|
|
27
|
+
iconColor = 'var(--blue)',
|
|
28
|
+
iconBg = 'rgba(39,130,228,.1)',
|
|
29
|
+
title,
|
|
30
|
+
value,
|
|
31
|
+
subtitle,
|
|
32
|
+
percent,
|
|
33
|
+
percentColor,
|
|
34
|
+
progressSize = 'sm',
|
|
35
|
+
progressTrackColor = '#e8e8ec',
|
|
36
|
+
headRight,
|
|
37
|
+
className = '',
|
|
38
|
+
style = {},
|
|
39
|
+
children,
|
|
40
|
+
}) {
|
|
41
|
+
if (!styleInjected && typeof document !== 'undefined') {
|
|
42
|
+
const tag = document.createElement('style');
|
|
43
|
+
tag.textContent = STYLES;
|
|
44
|
+
document.head.appendChild(tag);
|
|
45
|
+
styleInjected = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (variant === 'simple') {
|
|
49
|
+
return (
|
|
50
|
+
<div className={`scard scard-simple${className ? ' ' + className : ''}`} style={style}>
|
|
51
|
+
<div className="scard-head">
|
|
52
|
+
{title && <div className="scard-title">{title}</div>}
|
|
53
|
+
{icon && (
|
|
54
|
+
<div className="scard-icon-simple" style={{ color: iconColor }}>
|
|
55
|
+
{icon}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
{value !== undefined && <div className="scard-value">{value}</div>}
|
|
60
|
+
{children}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={`scard${className ? ' ' + className : ''}`} style={style}>
|
|
67
|
+
<div className="scard-left">
|
|
68
|
+
<div className="scard-head">
|
|
69
|
+
<div className="scard-head-left">
|
|
70
|
+
{icon && (
|
|
71
|
+
<div className="scard-icon" style={{ background: iconBg, color: iconColor }}>
|
|
72
|
+
{icon}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
{title && <div className="scard-title">{title}</div>}
|
|
76
|
+
</div>
|
|
77
|
+
{headRight}
|
|
78
|
+
</div>
|
|
79
|
+
{value !== undefined && <div className="scard-value">{value}</div>}
|
|
80
|
+
<div className="scard-sub" style={{ minHeight: 14 }}>{subtitle || '\u00A0'}</div>
|
|
81
|
+
{children}
|
|
82
|
+
</div>
|
|
83
|
+
<div className="scard-right">
|
|
84
|
+
{percent !== undefined && (
|
|
85
|
+
<CircularProgress
|
|
86
|
+
value={percent}
|
|
87
|
+
size={progressSize}
|
|
88
|
+
color={percentColor}
|
|
89
|
+
trackColor={progressTrackColor}
|
|
90
|
+
showPercent
|
|
91
|
+
rounded
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
package/TabBar.jsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TabBar — sliding underline indicator tabs
|
|
5
|
+
* Props:
|
|
6
|
+
* tabs: [{ id, label, icon?, count? }]
|
|
7
|
+
* active: string (active tab id)
|
|
8
|
+
* onChange: fn(id)
|
|
9
|
+
* capitalize: bool — when true, tab labels are text-transform: capitalize
|
|
10
|
+
* padding: string — override button padding (default '10px 16px')
|
|
11
|
+
*/
|
|
12
|
+
export default function TabBar({ tabs, active, onChange, capitalize = false, padding = '10px 16px' }) {
|
|
13
|
+
const barRef = useRef(null);
|
|
14
|
+
const indRef = useRef(null);
|
|
15
|
+
const initDone = useRef(false);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const bar = barRef.current;
|
|
19
|
+
const ind = indRef.current;
|
|
20
|
+
if (!bar || !ind) return;
|
|
21
|
+
|
|
22
|
+
const activeBtn = bar.querySelector(`[data-tabid="${active}"]`);
|
|
23
|
+
if (!activeBtn) return;
|
|
24
|
+
|
|
25
|
+
const barRect = bar.getBoundingClientRect();
|
|
26
|
+
const btnRect = activeBtn.getBoundingClientRect();
|
|
27
|
+
const x = btnRect.left - barRect.left;
|
|
28
|
+
const w = btnRect.width;
|
|
29
|
+
|
|
30
|
+
if (!initDone.current) {
|
|
31
|
+
ind.style.transition = 'none';
|
|
32
|
+
ind.style.width = w + 'px';
|
|
33
|
+
ind.style.transform = `translateX(${x}px)`;
|
|
34
|
+
requestAnimationFrame(() => { ind.style.transition = ''; });
|
|
35
|
+
initDone.current = true;
|
|
36
|
+
} else {
|
|
37
|
+
ind.style.width = w + 'px';
|
|
38
|
+
ind.style.transform = `translateX(${x}px)`;
|
|
39
|
+
}
|
|
40
|
+
}, [active, tabs]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div ref={barRef} style={{ display:'flex', position:'relative' }}>
|
|
44
|
+
{/* sliding underline */}
|
|
45
|
+
<div ref={indRef} style={{
|
|
46
|
+
position:'absolute', bottom:'-1px', left:0,
|
|
47
|
+
height:2, width:0,
|
|
48
|
+
background:'var(--accent)',
|
|
49
|
+
borderRadius:'2px 2px 0 0',
|
|
50
|
+
transition:'transform .22s cubic-bezier(.4,0,.2,1), width .22s cubic-bezier(.4,0,.2,1)',
|
|
51
|
+
pointerEvents:'none', zIndex:2,
|
|
52
|
+
}} />
|
|
53
|
+
|
|
54
|
+
{tabs.map(t => {
|
|
55
|
+
const isActive = active === t.id;
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
key={t.id}
|
|
59
|
+
data-tabid={t.id}
|
|
60
|
+
onClick={() => onChange(t.id)}
|
|
61
|
+
style={{
|
|
62
|
+
display:'flex', alignItems:'center', gap:6,
|
|
63
|
+
padding,
|
|
64
|
+
border:'none', background:'none',
|
|
65
|
+
fontSize:'.82rem', fontWeight:500,
|
|
66
|
+
color: isActive ? 'var(--accent)' : 'var(--muted)',
|
|
67
|
+
cursor:'pointer',
|
|
68
|
+
transition:'color .18s',
|
|
69
|
+
marginBottom:'-1px',
|
|
70
|
+
position:'relative', zIndex:1,
|
|
71
|
+
fontFamily:'inherit',
|
|
72
|
+
whiteSpace:'nowrap',
|
|
73
|
+
textTransform: capitalize ? 'capitalize' : 'none',
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{t.icon}{t.label}
|
|
77
|
+
{t.count != null && t.count > 0 && (
|
|
78
|
+
<span style={{
|
|
79
|
+
background: isActive ? 'var(--accent-bg)' : 'var(--grey-1)',
|
|
80
|
+
color: isActive ? 'var(--accent)' : 'var(--muted)',
|
|
81
|
+
borderRadius: 10,
|
|
82
|
+
padding: '1px 6px',
|
|
83
|
+
fontSize: '.68rem',
|
|
84
|
+
transition: 'background .18s, color .18s',
|
|
85
|
+
}}>{t.count}</span>
|
|
86
|
+
)}
|
|
87
|
+
</button>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|