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.
@@ -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
+ }
@@ -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
+ }