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/ImageUpload.jsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { Pencil, Upload, X, Trash2 } from 'lucide-react';
|
|
4
|
+
import Button from './Button.jsx';
|
|
5
|
+
|
|
6
|
+
export default function ImageUpload({ value, file, onChange, onRemove, label = 'Image' }) {
|
|
7
|
+
const [editOpen, setEditOpen] = useState(false);
|
|
8
|
+
const [urlInput, setUrlInput] = useState(value || '');
|
|
9
|
+
const [pendingFile, setPendingFile] = useState(null);
|
|
10
|
+
const [dragOver, setDragOver] = useState(false);
|
|
11
|
+
const [closing, setClosing] = useState(false);
|
|
12
|
+
|
|
13
|
+
const previewUrl = file ? URL.createObjectURL(file) : value;
|
|
14
|
+
const filename = file ? file.name : value ? value.split('/').pop() : null;
|
|
15
|
+
const hasImage = !!(file || value);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!editOpen) return;
|
|
19
|
+
function onKey(e) { if (e.key === 'Escape') handleClose(); }
|
|
20
|
+
document.addEventListener('keydown', onKey);
|
|
21
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
22
|
+
}, [editOpen]);
|
|
23
|
+
|
|
24
|
+
function handleFile(f) {
|
|
25
|
+
if (f && f.type.startsWith('image/')) {
|
|
26
|
+
setPendingFile(f);
|
|
27
|
+
setUrlInput('');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function handleSave() {
|
|
32
|
+
if (pendingFile) {
|
|
33
|
+
onChange({ file: pendingFile, url: '' });
|
|
34
|
+
} else if (urlInput.trim()) {
|
|
35
|
+
onChange({ file: null, url: urlInput.trim() });
|
|
36
|
+
}
|
|
37
|
+
handleClose();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handleClose() {
|
|
41
|
+
setClosing(true);
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
setEditOpen(false);
|
|
44
|
+
setClosing(false);
|
|
45
|
+
setPendingFile(null);
|
|
46
|
+
setUrlInput(value || '');
|
|
47
|
+
}, 260);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function openEdit() {
|
|
51
|
+
setUrlInput(value || '');
|
|
52
|
+
setPendingFile(null);
|
|
53
|
+
setEditOpen(true);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const modal = editOpen && createPortal(
|
|
57
|
+
<div className="modal-backdrop"
|
|
58
|
+
style={{ animation: `${closing ? 'backdropClose' : 'backdropFade'} .26s ease forwards` }}
|
|
59
|
+
onClick={e => { if (e.target === e.currentTarget) handleClose(); }}>
|
|
60
|
+
<div className="modal" style={{
|
|
61
|
+
maxWidth: 420,
|
|
62
|
+
animation: `${closing ? 'modalClose' : 'modalPop'} .26s cubic-bezier(.4,0,.2,1) forwards`,
|
|
63
|
+
}}>
|
|
64
|
+
<div className="modal-hdr">
|
|
65
|
+
<div className="modal-title">Edit {label}</div>
|
|
66
|
+
<Button variant="no-line" size="sm" icon={<X size={14} />} onClick={handleClose} />
|
|
67
|
+
</div>
|
|
68
|
+
<div className="modal-body" style={{ overflow: 'visible' }}>
|
|
69
|
+
<label
|
|
70
|
+
style={{
|
|
71
|
+
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
72
|
+
gap: 8, padding: '24px 16px', borderRadius: 'var(--rads)',
|
|
73
|
+
border: `1.5px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`,
|
|
74
|
+
background: 'var(--grey-1)', cursor: 'pointer', transition: 'all .15s', textAlign: 'center',
|
|
75
|
+
}}
|
|
76
|
+
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
|
77
|
+
onDragLeave={() => setDragOver(false)}
|
|
78
|
+
onDrop={e => { e.preventDefault(); setDragOver(false); handleFile(e.dataTransfer.files[0]); }}
|
|
79
|
+
>
|
|
80
|
+
<input type="file" accept="image/*,.ico" style={{ display: 'none' }}
|
|
81
|
+
onChange={e => handleFile(e.target.files[0])} />
|
|
82
|
+
{pendingFile ? (
|
|
83
|
+
<>
|
|
84
|
+
<img src={URL.createObjectURL(pendingFile)} alt="preview" style={{ height: 48, maxWidth: 160, objectFit: 'contain', borderRadius: 4 }} />
|
|
85
|
+
<div style={{ fontSize: '.76rem', fontWeight: 600, color: 'var(--accent)' }}>{pendingFile.name}</div>
|
|
86
|
+
<div style={{ fontSize: '.68rem', color: 'var(--muted)' }}>{(pendingFile.size / 1024).toFixed(1)} KB — click to change</div>
|
|
87
|
+
</>
|
|
88
|
+
) : (
|
|
89
|
+
<>
|
|
90
|
+
<Upload size={20} style={{ color: 'var(--muted)' }} />
|
|
91
|
+
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>Click or drag to upload</div>
|
|
92
|
+
<div style={{ fontSize: '.72rem', color: 'var(--muted)' }}>PNG, JPG, SVG, ICO up to 2MB</div>
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</label>
|
|
96
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '12px 0 4px' }}>
|
|
97
|
+
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
|
98
|
+
<span style={{ fontSize: '.72rem', color: 'var(--muted)' }}>or enter URL directly</span>
|
|
99
|
+
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
|
100
|
+
</div>
|
|
101
|
+
<input
|
|
102
|
+
type="url" placeholder="https://example.com/logo.png"
|
|
103
|
+
value={urlInput}
|
|
104
|
+
onChange={e => { setUrlInput(e.target.value); setPendingFile(null); }}
|
|
105
|
+
style={{
|
|
106
|
+
width: '100%', padding: '8px 10px', fontSize: '.82rem',
|
|
107
|
+
border: '1px solid var(--border)', borderRadius: 'var(--rads)',
|
|
108
|
+
background: 'var(--input-bg)', color: 'var(--primary-text)',
|
|
109
|
+
fontFamily: 'var(--sans)', outline: 'none',
|
|
110
|
+
}}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="modal-footer">
|
|
114
|
+
<Button variant="ghost" size="xs" onClick={handleClose}>Cancel</Button>
|
|
115
|
+
<Button variant="primary" size="xs" onClick={handleSave} disabled={!pendingFile && !urlInput.trim()}>Save</Button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>,
|
|
119
|
+
document.body
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<div style={{
|
|
125
|
+
display: 'flex', alignItems: 'center', gap: 12,
|
|
126
|
+
padding: '10px 12px', borderRadius: 'var(--rads)',
|
|
127
|
+
border: '1px solid var(--border)', background: 'var(--grey-1)',
|
|
128
|
+
}}>
|
|
129
|
+
{hasImage ? (
|
|
130
|
+
<img src={previewUrl} alt={label} style={{ height: 32, maxWidth: 80, objectFit: 'contain', borderRadius: 4, flexShrink: 0 }} />
|
|
131
|
+
) : (
|
|
132
|
+
<div style={{ width: 32, height: 32, borderRadius: 4, background: 'var(--grey-2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
133
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
<div style={{ flex: 1, fontSize: '.78rem', color: filename ? 'var(--primary-text)' : 'var(--muted)', fontFamily: filename ? 'var(--mono)' : 'inherit', wordBreak: 'break-all' }}>
|
|
137
|
+
{filename || 'Not set yet'}
|
|
138
|
+
</div>
|
|
139
|
+
<Button variant="ghost" size="xs" icon={<Pencil size={12} />} onClick={openEdit} style={{ flexShrink: 0 }}>Edit</Button>
|
|
140
|
+
{hasImage && (
|
|
141
|
+
<Button variant="ghost" size="xs" icon={<Trash2 size={12} />} style={{ color: 'var(--danger)', flexShrink: 0 }} onClick={onRemove}>Remove</Button>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
{modal}
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
}
|
package/Input.jsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const STYLES = `
|
|
4
|
+
.cinp{display:block;width:100%;background:var(--input-bg,#fff);border:1px solid var(--bd,#d7d8e0);border-radius:var(--rads,8px);color:var(--t1,#1a1a2e);font-family:var(--f,var(--sans,inherit));font-size:13px;padding:0 11px;outline:none;transition:border-color .14s,box-shadow .14s;box-shadow:var(--shadow,0 1px 3px rgba(20,22,41,.1))}
|
|
5
|
+
.cinp::placeholder{color:var(--t3,#9b9daa)}
|
|
6
|
+
.cinp:focus{border-color:var(--accent,#f99e2c);box-shadow:0 0 0 3px rgba(249,158,44,.12),var(--shadow,0 1px 3px rgba(20,22,41,.1))}
|
|
7
|
+
.cinp:disabled{opacity:.5;cursor:not-allowed;background:var(--grey-1,#f5f5f8)}
|
|
8
|
+
.cinp-error{border-color:var(--danger,#f64747)}
|
|
9
|
+
.cinp-error:focus{border-color:var(--danger,#f64747);box-shadow:0 0 0 3px rgba(246,71,71,.12)}
|
|
10
|
+
|
|
11
|
+
.cinp-xs{height:30px;font-size:11px;padding:0 8px}
|
|
12
|
+
.cinp-sm{height:32px;font-size:12px;padding:0 9px}
|
|
13
|
+
.cinp-md{height:34px;font-size:13px}
|
|
14
|
+
.cinp-lg{height:40px;font-size:14px;padding:0 14px}
|
|
15
|
+
|
|
16
|
+
.cinp-wrap{display:flex;flex-direction:column;gap:5px}
|
|
17
|
+
.cinp-wrap-xs .cinp-label{font-size:9px}
|
|
18
|
+
.cinp-label{font-size:11px;font-weight:600;color:var(--t2,#797b8d);text-transform:uppercase;letter-spacing:.08em}
|
|
19
|
+
.cinp-req{color:var(--danger,#f64747);margin-left:2px}
|
|
20
|
+
.cinp-hint{font-size:11px;color:var(--t3,#9b9daa)}
|
|
21
|
+
.cinp-err{font-size:11px;color:var(--danger,#f64747)}
|
|
22
|
+
|
|
23
|
+
.cinp-icon-left{position:relative}
|
|
24
|
+
.cinp-icon-left .cinp{padding-left:34px}
|
|
25
|
+
.cinp-icon-left .cinp-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--t3,#9b9daa);pointer-events:none;line-height:0}
|
|
26
|
+
.cinp-icon-left .cinp-icon svg{width:14px;height:14px}
|
|
27
|
+
|
|
28
|
+
.cinp-icon-right{position:relative}
|
|
29
|
+
.cinp-icon-right .cinp{padding-right:34px}
|
|
30
|
+
.cinp-icon-right .cinp-icon{position:absolute;right:10px;top:50%;transform:translateY(-50%);color:var(--t3,#9b9daa);pointer-events:none}
|
|
31
|
+
.cinp-icon-right .cinp-icon svg{width:14px;height:14px}
|
|
32
|
+
|
|
33
|
+
.cinp-wrap-clearable{position:relative}
|
|
34
|
+
.cinp-wrap-clearable .cinp{padding-right:30px}
|
|
35
|
+
.cinp-clear{position:absolute;right:8px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background:transparent;border:none;cursor:pointer;color:var(--t3,#9b9daa);padding:0;transition:background .12s,color .12s;line-height:0}
|
|
36
|
+
.cinp-clear:hover{background:var(--grey-1,#f0f0f4);color:var(--t1,#1a1a2e)}
|
|
37
|
+
|
|
38
|
+
.cinp-wrap-suffix{position:relative}
|
|
39
|
+
.cinp-wrap-suffix .cinp{padding-right:40px}
|
|
40
|
+
.cinp-suffix{position:absolute;right:8px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:4px;pointer-events:none}
|
|
41
|
+
|
|
42
|
+
textarea.cinp{resize:vertical;min-height:72px;height:auto;padding-top:8px;padding-bottom:8px;line-height:1.5}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
let styleInjected = false;
|
|
46
|
+
|
|
47
|
+
export default function Input({
|
|
48
|
+
label,
|
|
49
|
+
required = false,
|
|
50
|
+
hint,
|
|
51
|
+
error,
|
|
52
|
+
size = 'md',
|
|
53
|
+
icon,
|
|
54
|
+
iconRight,
|
|
55
|
+
clearable = false,
|
|
56
|
+
suffix,
|
|
57
|
+
onClear,
|
|
58
|
+
className = '',
|
|
59
|
+
style = {},
|
|
60
|
+
value,
|
|
61
|
+
...props
|
|
62
|
+
}) {
|
|
63
|
+
const inputRef = useRef(null);
|
|
64
|
+
|
|
65
|
+
if (!styleInjected && typeof document !== 'undefined') {
|
|
66
|
+
const tag = document.createElement('style');
|
|
67
|
+
tag.textContent = STYLES;
|
|
68
|
+
document.head.appendChild(tag);
|
|
69
|
+
styleInjected = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function handleClear() {
|
|
73
|
+
if (props.onChange) props.onChange({ target: { value: '' } });
|
|
74
|
+
if (onClear) onClear();
|
|
75
|
+
inputRef.current?.focus();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const hasIconLeft = !!icon;
|
|
79
|
+
const hasIconRight = !!iconRight;
|
|
80
|
+
const showClear = clearable && !!value && !props.disabled;
|
|
81
|
+
const hasSuffix = !!suffix;
|
|
82
|
+
const showRightSlot = showClear || hasSuffix;
|
|
83
|
+
|
|
84
|
+
const wrapClasses = [
|
|
85
|
+
'cinp-wrap',
|
|
86
|
+
`cinp-wrap-${size}`,
|
|
87
|
+
hasIconLeft && 'cinp-icon-left',
|
|
88
|
+
(hasIconRight || showRightSlot) && 'cinp-icon-right',
|
|
89
|
+
showRightSlot && !hasIconRight && 'cinp-wrap-clearable',
|
|
90
|
+
].filter(Boolean).join(' ');
|
|
91
|
+
|
|
92
|
+
const inputClass = `cinp cinp-${size}${error ? ' cinp-error' : ''}${className ? ' ' + className : ''}`;
|
|
93
|
+
|
|
94
|
+
const input = (
|
|
95
|
+
<input ref={inputRef} className={inputClass} style={style} value={value} {...props} />
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className={wrapClasses}>
|
|
100
|
+
{label && (
|
|
101
|
+
<label className="cinp-label">
|
|
102
|
+
{label}
|
|
103
|
+
{required && <span className="cinp-req">*</span>}
|
|
104
|
+
</label>
|
|
105
|
+
)}
|
|
106
|
+
{hasIconLeft && <span className="cinp-icon">{icon}</span>}
|
|
107
|
+
{hasIconRight && <span className="cinp-icon">{iconRight}</span>}
|
|
108
|
+
{input}
|
|
109
|
+
{showClear && (
|
|
110
|
+
<button type="button" className="cinp-clear" tabIndex={-1} onClick={handleClear}>
|
|
111
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
|
112
|
+
<line x1="1" y1="1" x2="9" y2="9" />
|
|
113
|
+
<line x1="9" y1="1" x2="1" y2="9" />
|
|
114
|
+
</svg>
|
|
115
|
+
</button>
|
|
116
|
+
)}
|
|
117
|
+
{hint && !error && <span className="cinp-hint">{hint}</span>}
|
|
118
|
+
{error && <span className="cinp-err">{error}</span>}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
package/Layout.jsx
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
}
|
package/LineProgress.jsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
size = 'md',
|
|
14
|
+
customSize,
|
|
15
|
+
color = 'var(--blue)',
|
|
16
|
+
trackColor = '#e0e0e4',
|
|
17
|
+
label = '',
|
|
18
|
+
showPercent = true,
|
|
19
|
+
animated = true,
|
|
20
|
+
rounded = true,
|
|
21
|
+
indeterminate = false,
|
|
22
|
+
speed = 1,
|
|
23
|
+
height,
|
|
24
|
+
gap = 5,
|
|
25
|
+
labelGap = 2,
|
|
26
|
+
className = '',
|
|
27
|
+
style = {},
|
|
28
|
+
}) {
|
|
29
|
+
const base = SIZES[size] || SIZES.md;
|
|
30
|
+
const barHeight = height ?? (customSize || base.height);
|
|
31
|
+
const fontSize = customSize ? `${Math.max(11, customSize * 1.6)}px` : base.fontSize;
|
|
32
|
+
const pct = Math.min(100, Math.max(0, value));
|
|
33
|
+
const radius = rounded ? barHeight / 2 : 0;
|
|
34
|
+
|
|
35
|
+
const barRef = useRef(null);
|
|
36
|
+
|
|
37
|
+
// Indeterminate animation
|
|
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
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className={className} style={{ display: 'inline-flex', flexDirection: 'column', gap: labelGap, width: '100%', ...style }}>
|
|
63
|
+
{(label || showPercent) && (
|
|
64
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
65
|
+
{label && <span style={{ fontSize, fontWeight: 600, color: 'var(--primary-text, #1a1a2e)' }}>{label}</span>}
|
|
66
|
+
{showPercent && !indeterminate && (
|
|
67
|
+
<span style={{ fontSize, fontWeight: 600, color: 'var(--muted, #9ca3af)', fontVariantNumeric: 'tabular-nums', marginLeft: label ? 0 : 'auto' }}>
|
|
68
|
+
{Math.round(pct)}%
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
<div style={{
|
|
74
|
+
height: barHeight, borderRadius: radius,
|
|
75
|
+
background: 'transparent', position: 'relative',
|
|
76
|
+
display: 'flex', gap: gap, overflow: 'hidden',
|
|
77
|
+
}}>
|
|
78
|
+
{indeterminate ? (
|
|
79
|
+
<>
|
|
80
|
+
<div style={{ flex: 1, borderRadius: radius, background: trackColor }} />
|
|
81
|
+
<div
|
|
82
|
+
ref={barRef}
|
|
83
|
+
style={{
|
|
84
|
+
position: 'absolute', top: 0, height: '100%',
|
|
85
|
+
width: '40%', borderRadius: radius,
|
|
86
|
+
background: color, opacity: 0.85,
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
</>
|
|
90
|
+
) : (
|
|
91
|
+
<>
|
|
92
|
+
<div ref={barRef} style={{
|
|
93
|
+
flex: pct > 0 ? pct : 0, minWidth: pct > 0 ? barHeight : 0,
|
|
94
|
+
borderRadius: radius, background: color,
|
|
95
|
+
transition: transitionStyle,
|
|
96
|
+
}} />
|
|
97
|
+
{pct < 100 && (
|
|
98
|
+
<div style={{
|
|
99
|
+
flex: 100 - pct,
|
|
100
|
+
borderRadius: radius, background: trackColor,
|
|
101
|
+
transition: transitionStyle,
|
|
102
|
+
}} />
|
|
103
|
+
)}
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
package/Modal.jsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import Button from './Button';
|
|
4
|
+
|
|
5
|
+
export function Modal({ open, onClose, onBeforeClose, title, children, footer, maxWidth = 520, bodyStyle }) {
|
|
6
|
+
const [closing, setClosing] = useState(false);
|
|
7
|
+
const mounted = useRef(open);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (open) {
|
|
11
|
+
mounted.current = true;
|
|
12
|
+
setClosing(false);
|
|
13
|
+
document.body.style.overflow = 'hidden';
|
|
14
|
+
}
|
|
15
|
+
return () => { document.body.style.overflow = ''; };
|
|
16
|
+
}, [open]);
|
|
17
|
+
|
|
18
|
+
function close() {
|
|
19
|
+
if (closing) return;
|
|
20
|
+
if (onBeforeClose && onBeforeClose() === false) return;
|
|
21
|
+
setClosing(true);
|
|
22
|
+
setTimeout(() => {
|
|
23
|
+
mounted.current = false;
|
|
24
|
+
setClosing(false);
|
|
25
|
+
onClose();
|
|
26
|
+
}, 260);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!open) return;
|
|
31
|
+
function onKey(e) { if (e.key === 'Escape') close(); }
|
|
32
|
+
document.addEventListener('keydown', onKey);
|
|
33
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
34
|
+
}, [open, closing]);
|
|
35
|
+
|
|
36
|
+
if (!open && !closing) return null;
|
|
37
|
+
|
|
38
|
+
const resolvedFooter = typeof footer === 'function' ? footer(close) : footer;
|
|
39
|
+
const resolvedChildren = typeof children === 'function' ? children(close) : children;
|
|
40
|
+
|
|
41
|
+
return createPortal(
|
|
42
|
+
<div
|
|
43
|
+
className="modal-backdrop"
|
|
44
|
+
style={{ animation: `${closing ? 'modalFadeOut' : 'modalFadeIn'} .26s ease forwards` }}
|
|
45
|
+
onMouseDown={e => { if (e.target === e.currentTarget) close(); }}
|
|
46
|
+
>
|
|
47
|
+
<div
|
|
48
|
+
className="modal-card"
|
|
49
|
+
style={{
|
|
50
|
+
maxWidth,
|
|
51
|
+
animation: `${closing ? 'modalCardOut' : 'modalCardIn'} .26s cubic-bezier(.4,0,.2,1) forwards`,
|
|
52
|
+
}}
|
|
53
|
+
onMouseDown={e => e.stopPropagation()}
|
|
54
|
+
>
|
|
55
|
+
<div className="modal-hdr">
|
|
56
|
+
<span className="modal-title">{title}</span>
|
|
57
|
+
<button className="modal-close" onClick={close}>
|
|
58
|
+
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"
|
|
59
|
+
strokeWidth="2" strokeLinecap="round">
|
|
60
|
+
<line x1="1.5" y1="1.5" x2="11.5" y2="11.5"/>
|
|
61
|
+
<line x1="11.5" y1="1.5" x2="1.5" y2="11.5"/>
|
|
62
|
+
</svg>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="modal-body" style={{ overflowX: 'hidden', overflowY: 'auto', flex: 1, minHeight: 0, ...bodyStyle }}>{resolvedChildren}</div>
|
|
66
|
+
{resolvedFooter && <div className="modal-footer">{resolvedFooter}</div>}
|
|
67
|
+
</div>
|
|
68
|
+
</div>,
|
|
69
|
+
document.body
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function Confirm({ open, onClose, onConfirm, title, message, variant = 'danger', confirmLabel = 'Confirm', loading = false }) {
|
|
74
|
+
const iconColor = { danger: 'var(--danger)', warning: 'var(--accent)', info: 'var(--blue)' }[variant] || 'var(--danger)';
|
|
75
|
+
const btnVariant = variant === 'danger' ? 'danger' : 'primary';
|
|
76
|
+
|
|
77
|
+
const icon = variant === 'danger'
|
|
78
|
+
? <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>
|
|
79
|
+
: <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>;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Modal open={open} onClose={onClose} title="" maxWidth={400}
|
|
83
|
+
footer={
|
|
84
|
+
<>
|
|
85
|
+
<Button variant="ghost" size="sm" onClick={onClose} disabled={loading}>Cancel</Button>
|
|
86
|
+
<Button variant={btnVariant} size="sm" onClick={onConfirm} disabled={loading}>
|
|
87
|
+
{loading ? 'Please wait...' : confirmLabel}
|
|
88
|
+
</Button>
|
|
89
|
+
</>
|
|
90
|
+
}>
|
|
91
|
+
<div style={{ textAlign: 'center', padding: '4px 0 8px' }}>
|
|
92
|
+
<div className={`confirm-icon ${variant}`}>{icon}</div>
|
|
93
|
+
<div style={{ fontSize: '.9rem', fontWeight: 700, marginBottom: 8 }}>{title}</div>
|
|
94
|
+
{message && <div style={{ fontSize: '.8rem', color: 'var(--muted)', lineHeight: 1.5 }}>{message}</div>}
|
|
95
|
+
</div>
|
|
96
|
+
</Modal>
|
|
97
|
+
);
|
|
98
|
+
}
|