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/DatePicker.jsx
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
// DatePicker — reusable date picker component
|
|
5
|
+
// Props:
|
|
6
|
+
// value – Date object or null
|
|
7
|
+
// onChange – called with Date object
|
|
8
|
+
// placeholder – string shown when no date selected
|
|
9
|
+
// badges – object { 'YYYY-MM-DD': { dot: true, color: 'var(--accent)' } } for day badges
|
|
10
|
+
// minDate – Date object, days before this are disabled
|
|
11
|
+
// maxDate – Date object, days after this are disabled
|
|
12
|
+
// disabled – boolean
|
|
13
|
+
// style – container style overrides
|
|
14
|
+
|
|
15
|
+
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
|
16
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
17
|
+
|
|
18
|
+
function pad(n) { return String(n).padStart(2, '0'); }
|
|
19
|
+
function toKey(y, m, d) { return `${y}-${pad(m+1)}-${pad(d)}`; }
|
|
20
|
+
function isSameDay(a, b) { return a && b && a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); }
|
|
21
|
+
function isToday(y, m, d) { const t = new Date(); return t.getFullYear()===y && t.getMonth()===m && t.getDate()===d; }
|
|
22
|
+
|
|
23
|
+
export function DatePicker({ value, onChange, placeholder = 'Select date', badges = {}, minDate, maxDate, disabled, style }) {
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const [view, setView] = useState('day'); // 'day' | 'month' | 'year'
|
|
26
|
+
const [cursor, setCursor] = useState(() => value ? new Date(value) : new Date());
|
|
27
|
+
const ref = useRef(null);
|
|
28
|
+
|
|
29
|
+
// Close on outside click
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!open) return;
|
|
32
|
+
function close(e) { if (!ref.current?.contains(e.target)) { setOpen(false); setView('day'); } }
|
|
33
|
+
document.addEventListener('mousedown', close);
|
|
34
|
+
return () => document.removeEventListener('mousedown', close);
|
|
35
|
+
}, [open]);
|
|
36
|
+
|
|
37
|
+
// Sync cursor when value changes externally
|
|
38
|
+
useEffect(() => { if (value) setCursor(new Date(value)); }, [value]);
|
|
39
|
+
|
|
40
|
+
function pick(date) {
|
|
41
|
+
onChange?.(date);
|
|
42
|
+
setOpen(false);
|
|
43
|
+
setView('day');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nav(dir) {
|
|
47
|
+
const c = new Date(cursor);
|
|
48
|
+
if (view === 'day') { c.setMonth(c.getMonth() + dir); }
|
|
49
|
+
if (view === 'month') { c.setFullYear(c.getFullYear() + dir); }
|
|
50
|
+
if (view === 'year') { c.setFullYear(c.getFullYear() + dir * 12); }
|
|
51
|
+
setCursor(c);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cycleView() {
|
|
55
|
+
setView(v => v === 'day' ? 'month' : v === 'month' ? 'year' : 'year');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const formatted = value
|
|
59
|
+
? `${MONTHS[value.getMonth()]} ${value.getDate()}, ${value.getFullYear()}`
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
// ── Day grid ─────────────────────────────────────────────────
|
|
63
|
+
function renderDays() {
|
|
64
|
+
const y = cursor.getFullYear(), m = cursor.getMonth();
|
|
65
|
+
const first = new Date(y, m, 1).getDay(); // 0=Sun
|
|
66
|
+
const offset = first === 0 ? 6 : first === 1 ? 7 : first - 1; // 0=Sun→6, 1=Mon→7(show prev week), else Mon-start
|
|
67
|
+
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
|
68
|
+
const prevDays = new Date(y, m, 0).getDate();
|
|
69
|
+
|
|
70
|
+
const cells = [];
|
|
71
|
+
// Prev month filler
|
|
72
|
+
for (let i = offset - 1; i >= 0; i--)
|
|
73
|
+
cells.push({ day: prevDays - i, cur: false, next: false });
|
|
74
|
+
// Current month
|
|
75
|
+
for (let d = 1; d <= daysInMonth; d++)
|
|
76
|
+
cells.push({ day: d, cur: true, next: false });
|
|
77
|
+
// Next month filler — complete last row + always show at least one full next month week
|
|
78
|
+
const totalSoFar = cells.length;
|
|
79
|
+
let remaining = (7 - (totalSoFar % 7)) % 7;
|
|
80
|
+
if (remaining === 0) remaining = 7;
|
|
81
|
+
for (let d = 1; d <= remaining; d++)
|
|
82
|
+
cells.push({ day: d, cur: false, next: true });
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div>
|
|
86
|
+
{/* Weekday headers */}
|
|
87
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 4 }}>
|
|
88
|
+
{DAYS.map(d => (
|
|
89
|
+
<div key={d} style={{ textAlign: 'center', fontSize: '.68rem', fontWeight: 700, color: 'var(--muted)', padding: '4px 0', fontFamily: 'var(--mono)' }}>{d}</div>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
{/* Day cells */}
|
|
93
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 1 }}>
|
|
94
|
+
{cells.map((c, i) => {
|
|
95
|
+
if (!c.cur) {
|
|
96
|
+
const isPrev = !c.next;
|
|
97
|
+
const date = isPrev
|
|
98
|
+
? new Date(y, m - 1, c.day)
|
|
99
|
+
: new Date(y, m + 1, c.day);
|
|
100
|
+
return (
|
|
101
|
+
<div key={i} onClick={() => { pick(date); }}
|
|
102
|
+
style={{ textAlign: 'center', padding: '6px 2px', fontSize: '.78rem', color: 'var(--muted)', cursor: 'pointer', borderRadius: 6, transition: 'background .12s' }}
|
|
103
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--grey-1)'}
|
|
104
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
|
105
|
+
{c.day}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const date = new Date(y, m, c.day);
|
|
110
|
+
const key = toKey(y, m, c.day);
|
|
111
|
+
const badge = badges[key];
|
|
112
|
+
const sel = isSameDay(date, value);
|
|
113
|
+
const today = isToday(y, m, c.day);
|
|
114
|
+
const disMin = minDate && date < new Date(minDate.setHours(0,0,0,0));
|
|
115
|
+
const disMax = maxDate && date > new Date(maxDate.setHours(23,59,59,999));
|
|
116
|
+
const dis = disMin || disMax;
|
|
117
|
+
return (
|
|
118
|
+
<div key={i} onClick={() => !dis && pick(date)} style={{
|
|
119
|
+
position: 'relative', textAlign: 'center', padding: '6px 2px',
|
|
120
|
+
fontSize: '.8rem', fontWeight: sel ? 700 : today ? 600 : 400,
|
|
121
|
+
borderRadius: 6, cursor: dis ? 'not-allowed' : 'pointer',
|
|
122
|
+
background: sel ? 'var(--accent)' : 'transparent',
|
|
123
|
+
color: sel ? '#fff' : dis ? 'var(--border)' : today ? 'var(--accent)' : 'var(--primary-text)',
|
|
124
|
+
border: today && !sel ? '1.5px solid var(--accent)' : '1.5px solid transparent',
|
|
125
|
+
transition: 'background .12s, color .12s',
|
|
126
|
+
}}
|
|
127
|
+
onMouseEnter={e => { if (!sel && !dis) e.currentTarget.style.background = 'var(--grey-1)'; }}
|
|
128
|
+
onMouseLeave={e => { if (!sel && !dis) e.currentTarget.style.background = 'transparent'; }}>
|
|
129
|
+
{c.day}
|
|
130
|
+
{badge && (
|
|
131
|
+
<span style={{
|
|
132
|
+
position: 'absolute', bottom: 2, left: '50%', transform: 'translateX(-50%)',
|
|
133
|
+
width: 4, height: 4, borderRadius: '50%',
|
|
134
|
+
background: badge.color || 'var(--accent)',
|
|
135
|
+
}} />
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Month grid ───────────────────────────────────────────────
|
|
146
|
+
function renderMonths() {
|
|
147
|
+
const y = cursor.getFullYear();
|
|
148
|
+
return (
|
|
149
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, padding: '4px 0' }}>
|
|
150
|
+
{MONTHS.map((name, i) => {
|
|
151
|
+
const sel = value && value.getFullYear() === y && value.getMonth() === i;
|
|
152
|
+
return (
|
|
153
|
+
<div key={i} onClick={() => { const c = new Date(cursor); c.setMonth(i); setCursor(c); setView('day'); }}
|
|
154
|
+
style={{ textAlign: 'center', padding: '10px 4px', borderRadius: 6, fontSize: '.82rem', fontWeight: 600, cursor: 'pointer',
|
|
155
|
+
background: sel ? 'var(--accent)' : 'transparent', color: sel ? '#fff' : 'var(--primary-text)',
|
|
156
|
+
transition: 'background .12s', }}
|
|
157
|
+
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--grey-1)'; }}
|
|
158
|
+
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = 'transparent'; }}>
|
|
159
|
+
{name}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Year grid ────────────────────────────────────────────────
|
|
168
|
+
function renderYears() {
|
|
169
|
+
const base = Math.floor(cursor.getFullYear() / 12) * 12;
|
|
170
|
+
const years = Array.from({ length: 12 }, (_, i) => base + i);
|
|
171
|
+
return (
|
|
172
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, padding: '4px 0' }}>
|
|
173
|
+
{years.map(yr => {
|
|
174
|
+
const sel = value && value.getFullYear() === yr;
|
|
175
|
+
return (
|
|
176
|
+
<div key={yr} onClick={() => { const c = new Date(cursor); c.setFullYear(yr); setCursor(c); setView('month'); }}
|
|
177
|
+
style={{ textAlign: 'center', padding: '10px 4px', borderRadius: 6, fontSize: '.82rem', fontWeight: 600, cursor: 'pointer',
|
|
178
|
+
background: sel ? 'var(--accent)' : 'transparent', color: sel ? '#fff' : 'var(--primary-text)',
|
|
179
|
+
transition: 'background .12s', }}
|
|
180
|
+
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--grey-1)'; }}
|
|
181
|
+
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = 'transparent'; }}>
|
|
182
|
+
{yr}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const headerLabel = view === 'day'
|
|
191
|
+
? `${MONTHS[cursor.getMonth()]} ${cursor.getFullYear()}`
|
|
192
|
+
: view === 'month'
|
|
193
|
+
? `${cursor.getFullYear()}`
|
|
194
|
+
: `${Math.floor(cursor.getFullYear()/12)*12} – ${Math.floor(cursor.getFullYear()/12)*12+11}`;
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div ref={ref} style={{ position: 'relative', display: 'inline-block', ...style }}>
|
|
198
|
+
{/* Trigger */}
|
|
199
|
+
<button type="button" onClick={() => !disabled && setOpen(o => !o)} style={{
|
|
200
|
+
display: 'flex', alignItems: 'center', gap: 7,
|
|
201
|
+
padding: '5px 10px', borderRadius: 'var(--rads)',
|
|
202
|
+
border: '1px solid var(--border)', background: 'var(--surface)',
|
|
203
|
+
cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.5 : 1,
|
|
204
|
+
fontSize: '.8rem', color: formatted ? 'var(--primary-text)' : 'var(--muted)',
|
|
205
|
+
fontFamily: 'var(--sans)', transition: 'border-color .15s',
|
|
206
|
+
whiteSpace: 'nowrap', minWidth: 140, height: 32, boxShadow: 'var(--shadow)',
|
|
207
|
+
}}
|
|
208
|
+
onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--accent)'; }}
|
|
209
|
+
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; }}>
|
|
210
|
+
<Calendar size={13} style={{ color: 'var(--muted)', flexShrink: 0 }} />
|
|
211
|
+
<span style={{ flex: 1, textAlign: 'left' }}>{formatted || placeholder}</span>
|
|
212
|
+
{value && (
|
|
213
|
+
<span
|
|
214
|
+
onClick={e => { e.stopPropagation(); onChange?.(null); }}
|
|
215
|
+
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--muted)', cursor: 'pointer', flexShrink: 0, fontSize: 13, lineHeight: 1, padding: '0 2px' }}
|
|
216
|
+
onMouseEnter={e => { e.currentTarget.style.color = 'var(--danger)'; }}
|
|
217
|
+
onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted)'; }}>
|
|
218
|
+
✕
|
|
219
|
+
</span>
|
|
220
|
+
)}
|
|
221
|
+
</button>
|
|
222
|
+
|
|
223
|
+
{/* Dropdown */}
|
|
224
|
+
{open && (
|
|
225
|
+
<div style={{
|
|
226
|
+
position: 'absolute', top: 'calc(100% + 6px)', left: 0, zIndex: 999,
|
|
227
|
+
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
228
|
+
borderRadius: 10, boxShadow: 'var(--shadow-lg)',
|
|
229
|
+
padding: '10px 12px', width: 252,
|
|
230
|
+
animation: 'dp-in .12s ease',
|
|
231
|
+
}}>
|
|
232
|
+
{/* Calendar header */}
|
|
233
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
234
|
+
<button type="button" onClick={() => nav(-1)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, color: 'var(--muted)', display: 'flex', alignItems: 'center' }}
|
|
235
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--grey-1)'}
|
|
236
|
+
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
|
237
|
+
<ChevronLeft size={15} />
|
|
238
|
+
</button>
|
|
239
|
+
<button type="button" onClick={cycleView} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '.82rem', fontWeight: 700, color: 'var(--primary-text)', padding: '4px 8px', borderRadius: 6, fontFamily: 'var(--sans)' }}
|
|
240
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--grey-1)'}
|
|
241
|
+
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
|
242
|
+
{headerLabel}
|
|
243
|
+
</button>
|
|
244
|
+
<button type="button" onClick={() => nav(1)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, color: 'var(--muted)', display: 'flex', alignItems: 'center' }}
|
|
245
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--grey-1)'}
|
|
246
|
+
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
|
247
|
+
<ChevronRight size={15} />
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{/* Calendar body */}
|
|
252
|
+
{view === 'day' && renderDays()}
|
|
253
|
+
{view === 'month' && renderMonths()}
|
|
254
|
+
{view === 'year' && renderYears()}
|
|
255
|
+
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
<style>{`@keyframes dp-in { from { opacity:0; transform:translateY(-4px); } to { opacity:1; transform:none; } }`}</style>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import Modal from './Modal.jsx';
|
|
3
|
+
import { useAuth } from '../hooks/useAuth.jsx';
|
|
4
|
+
import Input from './Input.jsx';
|
|
5
|
+
import Spinner from './Spinner.jsx';
|
|
6
|
+
|
|
7
|
+
export default function DeleteConfirmModal({ title, warning, confirm: confirmMsg, matchText, onConfirm, onClose }) {
|
|
8
|
+
const { prefs } = useAuth();
|
|
9
|
+
const mode = prefs?.delete_confirm_mode || 'password';
|
|
10
|
+
const confirmText = prefs?.delete_confirm_text || 'delete';
|
|
11
|
+
const [step, setStep] = useState(1);
|
|
12
|
+
const [value, setValue] = useState('');
|
|
13
|
+
const [error, setError] = useState('');
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
const [showPass, setShowPass] = useState(false);
|
|
16
|
+
|
|
17
|
+
async function handleConfirm() {
|
|
18
|
+
setError('');
|
|
19
|
+
if (mode === 'text' && value !== confirmText) {
|
|
20
|
+
setError(`Type "${confirmText}" exactly to confirm`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (mode === 'password' && !value) {
|
|
24
|
+
setError('Password required');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
setLoading(true);
|
|
28
|
+
try {
|
|
29
|
+
await onConfirm(mode === 'password' ? value : null);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
setError(e.message || 'Confirmation failed');
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EyeIcon = () => (
|
|
38
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
39
|
+
{showPass
|
|
40
|
+
? <><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></>
|
|
41
|
+
: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></>
|
|
42
|
+
}
|
|
43
|
+
</svg>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Modal
|
|
48
|
+
title={title}
|
|
49
|
+
onClose={onClose}
|
|
50
|
+
footer={close => (
|
|
51
|
+
step === 1 ? (
|
|
52
|
+
<>
|
|
53
|
+
<button className="btn btn-ghost" onClick={close}>Cancel</button>
|
|
54
|
+
<button className="btn btn-danger" onClick={() => setStep(2)}>Continue</button>
|
|
55
|
+
</>
|
|
56
|
+
) : (
|
|
57
|
+
<>
|
|
58
|
+
<button className="btn btn-ghost" onClick={onClose}>Cancel</button>
|
|
59
|
+
<button className="btn btn-danger" onClick={handleConfirm} disabled={loading || !value}>
|
|
60
|
+
{loading ? <><Spinner inline /> Deleting...</> : 'Delete'}
|
|
61
|
+
</button>
|
|
62
|
+
</>
|
|
63
|
+
)
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{step === 1 ? (
|
|
67
|
+
<div style={{ display:'flex', gap:12, alignItems:'flex-start' }}>
|
|
68
|
+
<div style={{ width:36, height:36, borderRadius:'50%', background:'var(--danger-bg, #fff0f0)', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>
|
|
69
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
70
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
|
71
|
+
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
72
|
+
</svg>
|
|
73
|
+
</div>
|
|
74
|
+
<div style={{ fontSize:'.88rem', lineHeight:1.6 }}>{warning}</div>
|
|
75
|
+
</div>
|
|
76
|
+
) : (
|
|
77
|
+
<div>
|
|
78
|
+
<p style={{ fontSize:'.88rem', marginBottom:16, lineHeight:1.6 }}>{confirmMsg}</p>
|
|
79
|
+
{mode === 'password' ? (
|
|
80
|
+
<div className="form-group" style={{ marginBottom:0 }}>
|
|
81
|
+
<label>Enter your password to confirm</label>
|
|
82
|
+
<div style={{ position:'relative' }}>
|
|
83
|
+
<Input
|
|
84
|
+
type={showPass ? 'text' : 'password'}
|
|
85
|
+
placeholder="Your account password"
|
|
86
|
+
value={value}
|
|
87
|
+
onChange={e => setValue(e.target.value)}
|
|
88
|
+
onKeyDown={e => e.key === 'Enter' && handleConfirm()}
|
|
89
|
+
style={{ width:'100%', paddingRight:36 }}
|
|
90
|
+
autoFocus
|
|
91
|
+
/>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => setShowPass(v => !v)}
|
|
95
|
+
style={{ position:'absolute', right:10, top:'50%', transform:'translateY(-50%)', background:'none', border:'none', cursor:'pointer', color:'var(--muted)', padding:0, display:'flex', alignItems:'center' }}
|
|
96
|
+
>
|
|
97
|
+
<EyeIcon />
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
<div className="form-group" style={{ marginBottom:0 }}>
|
|
103
|
+
<label>Type <strong>{confirmText}</strong> to confirm</label>
|
|
104
|
+
<Input
|
|
105
|
+
type="text"
|
|
106
|
+
placeholder={confirmText}
|
|
107
|
+
value={value}
|
|
108
|
+
onChange={e => setValue(e.target.value)}
|
|
109
|
+
onKeyDown={e => e.key === 'Enter' && handleConfirm()}
|
|
110
|
+
autoFocus
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
{error && <div style={{ color:'var(--danger)', fontSize:'.78rem', marginTop:8 }}>{error}</div>}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</Modal>
|
|
118
|
+
);
|
|
119
|
+
}
|
package/Dropdown.jsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { ChevronDown, Check } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export default function Dropdown({ value, onChange, options, placeholder = 'Select...', disabled }) {
|
|
6
|
+
const [open, setOpen] = useState(false);
|
|
7
|
+
const [rect, setRect] = useState(null);
|
|
8
|
+
const btnRef = useRef();
|
|
9
|
+
const menuRef = useRef();
|
|
10
|
+
|
|
11
|
+
const updateRect = useCallback(() => {
|
|
12
|
+
if (btnRef.current) setRect(btnRef.current.getBoundingClientRect());
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
// Close on outside click
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!open) return;
|
|
18
|
+
function onDown(e) {
|
|
19
|
+
if (!btnRef.current?.contains(e.target) && !menuRef.current?.contains(e.target))
|
|
20
|
+
setOpen(false);
|
|
21
|
+
}
|
|
22
|
+
document.addEventListener('mousedown', onDown);
|
|
23
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
24
|
+
}, [open]);
|
|
25
|
+
|
|
26
|
+
// Update position on scroll/resize while open
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!open) return;
|
|
29
|
+
window.addEventListener('scroll', updateRect, true);
|
|
30
|
+
window.addEventListener('resize', updateRect);
|
|
31
|
+
return () => {
|
|
32
|
+
window.removeEventListener('scroll', updateRect, true);
|
|
33
|
+
window.removeEventListener('resize', updateRect);
|
|
34
|
+
};
|
|
35
|
+
}, [open, updateRect]);
|
|
36
|
+
|
|
37
|
+
function toggle() {
|
|
38
|
+
if (disabled) return;
|
|
39
|
+
updateRect();
|
|
40
|
+
setOpen(o => !o);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const selected = options.find(o => String(o.value) === String(value));
|
|
44
|
+
|
|
45
|
+
const menu = open && rect && createPortal(
|
|
46
|
+
<div
|
|
47
|
+
ref={menuRef}
|
|
48
|
+
style={{
|
|
49
|
+
position: 'fixed',
|
|
50
|
+
top: rect.bottom + 4,
|
|
51
|
+
left: rect.left,
|
|
52
|
+
width: rect.width,
|
|
53
|
+
background: 'var(--surface)',
|
|
54
|
+
border: '1px solid var(--border)',
|
|
55
|
+
borderRadius: 'var(--rads)',
|
|
56
|
+
boxShadow: 'var(--shadow-lg)',
|
|
57
|
+
zIndex: 999999,
|
|
58
|
+
animation: 'dropdownOpen .2s cubic-bezier(.16,1,.3,1)',
|
|
59
|
+
}}>
|
|
60
|
+
{options.map(o => {
|
|
61
|
+
const active = String(o.value) === String(value);
|
|
62
|
+
return (
|
|
63
|
+
<div key={o.value}
|
|
64
|
+
onMouseDown={e => e.preventDefault()}
|
|
65
|
+
onClick={() => { onChange(o.value); setOpen(false); }}
|
|
66
|
+
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--hover-bg)'; }}
|
|
67
|
+
onMouseLeave={e => { e.currentTarget.style.background = active ? 'var(--accent-bg)' : 'transparent'; }}
|
|
68
|
+
style={{
|
|
69
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
70
|
+
padding: '9px 12px', cursor: 'pointer', fontSize: '.82rem',
|
|
71
|
+
color: active ? 'var(--accent)' : 'var(--primary-text)',
|
|
72
|
+
background: active ? 'var(--accent-bg)' : 'transparent',
|
|
73
|
+
fontWeight: active ? 600 : 400,
|
|
74
|
+
}}>
|
|
75
|
+
<span>{o.label}</span>
|
|
76
|
+
{active && <Check size={12} />}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</div>,
|
|
81
|
+
document.body
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div ref={btnRef} style={{ position: 'relative' }}>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
disabled={disabled}
|
|
89
|
+
onClick={toggle}
|
|
90
|
+
style={{
|
|
91
|
+
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
92
|
+
padding: '8px 11px', background: 'var(--input-bg)',
|
|
93
|
+
border: `1px solid ${open ? 'var(--accent)' : 'var(--border)'}`,
|
|
94
|
+
boxShadow: open ? '0 0 0 3px rgba(249,158,44,.12)' : 'var(--shadow)',
|
|
95
|
+
borderRadius: 'var(--rads)', cursor: disabled ? 'not-allowed' : 'pointer',
|
|
96
|
+
color: selected ? 'var(--primary-text)' : 'var(--muted)',
|
|
97
|
+
fontSize: '.82rem', fontFamily: 'var(--sans)', transition: 'all .14s',
|
|
98
|
+
opacity: disabled ? .5 : 1, gap: 8,
|
|
99
|
+
}}>
|
|
100
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
101
|
+
{selected ? selected.label : placeholder}
|
|
102
|
+
</span>
|
|
103
|
+
<ChevronDown size={13} style={{ color: 'var(--muted)', flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }} />
|
|
104
|
+
</button>
|
|
105
|
+
{menu}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
package/Dropzone.jsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useState, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
export function Dropzone({ onFile, accept, hint, file }) {
|
|
4
|
+
const [over, setOver] = useState(false);
|
|
5
|
+
const inputRef = useRef(null);
|
|
6
|
+
return (
|
|
7
|
+
<div className={`dropzone${over ? ' over' : ''}`}
|
|
8
|
+
onDragOver={e => { e.preventDefault(); setOver(true); }}
|
|
9
|
+
onDragLeave={() => setOver(false)}
|
|
10
|
+
onDrop={e => { e.preventDefault(); setOver(false); const f = e.dataTransfer.files[0]; if (f) onFile(f); }}
|
|
11
|
+
onClick={() => inputRef.current?.click()}>
|
|
12
|
+
<input ref={inputRef} type="file" accept={accept}
|
|
13
|
+
onChange={e => e.target.files[0] && onFile(e.target.files[0])} style={{ display: 'none' }} />
|
|
14
|
+
<div className="dropzone-icon">
|
|
15
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--grey-3)" strokeWidth="1.5" strokeLinecap="round">
|
|
16
|
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
|
17
|
+
</svg>
|
|
18
|
+
</div>
|
|
19
|
+
{file ? (
|
|
20
|
+
<div className="dropzone-file">{file.name}</div>
|
|
21
|
+
) : (
|
|
22
|
+
<>
|
|
23
|
+
<div className="dropzone-text"><b>Click to select</b> or drag & drop</div>
|
|
24
|
+
{hint && <div className="dropzone-hint">{hint}</div>}
|
|
25
|
+
</>
|
|
26
|
+
)}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
package/HoverCard.jsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HoverCard — reusable tooltip/popover on hover
|
|
5
|
+
*
|
|
6
|
+
* Props:
|
|
7
|
+
* trigger — React node as hover target
|
|
8
|
+
* children — popup content
|
|
9
|
+
* position — 'top' | 'bottom' (default: 'top')
|
|
10
|
+
* width — min-width (default: 180)
|
|
11
|
+
* style — extra popup styles
|
|
12
|
+
* triggerStyle — extra trigger wrapper styles
|
|
13
|
+
*/
|
|
14
|
+
export default function HoverCard({ trigger, children, position = 'top', width = 180, style, triggerStyle }) {
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
17
|
+
const wrapRef = useRef(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (open && wrapRef.current) {
|
|
21
|
+
const r = wrapRef.current.getBoundingClientRect();
|
|
22
|
+
setPos({ x: r.left + r.width / 2, y: position === 'top' ? r.top : r.bottom });
|
|
23
|
+
}
|
|
24
|
+
}, [open, position]);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!open) return;
|
|
28
|
+
const close = (e) => {
|
|
29
|
+
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
|
30
|
+
};
|
|
31
|
+
document.addEventListener('mousedown', close);
|
|
32
|
+
return () => document.removeEventListener('mousedown', close);
|
|
33
|
+
}, [open]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
ref={wrapRef}
|
|
38
|
+
onMouseEnter={() => setOpen(true)}
|
|
39
|
+
onMouseLeave={() => setOpen(false)}
|
|
40
|
+
style={{ display: 'inline-block', position: 'relative', ...triggerStyle }}
|
|
41
|
+
>
|
|
42
|
+
{trigger}
|
|
43
|
+
{open && (
|
|
44
|
+
<div style={{
|
|
45
|
+
position: 'fixed',
|
|
46
|
+
left: pos.x,
|
|
47
|
+
top: position === 'top' ? 'auto' : pos.y + 6,
|
|
48
|
+
bottom: position === 'top' ? window.innerHeight - pos.y + 6 : 'auto',
|
|
49
|
+
transform: 'translateX(-50%)',
|
|
50
|
+
zIndex: 9999,
|
|
51
|
+
minWidth: width,
|
|
52
|
+
background: '#fff',
|
|
53
|
+
border: '1px solid var(--border)',
|
|
54
|
+
borderRadius: 'var(--rads, 8px)',
|
|
55
|
+
padding: '10px 14px',
|
|
56
|
+
boxShadow: '0 4px 12px rgba(0,0,0,.12)',
|
|
57
|
+
pointerEvents: 'none',
|
|
58
|
+
animation: 'hoverCardIn .15s ease',
|
|
59
|
+
...style,
|
|
60
|
+
}}>
|
|
61
|
+
{children}
|
|
62
|
+
<div style={{
|
|
63
|
+
position: 'absolute', left: '50%',
|
|
64
|
+
[position === 'top' ? 'bottom' : 'top']: -5,
|
|
65
|
+
transform: 'translateX(-50%) rotate(45deg)',
|
|
66
|
+
width: 8, height: 8, background: '#fff',
|
|
67
|
+
borderRight: '1px solid var(--border)',
|
|
68
|
+
borderBottom: position === 'top' ? 'none' : '1px solid var(--border)',
|
|
69
|
+
borderTop: position === 'top' ? '1px solid var(--border)' : 'none',
|
|
70
|
+
}} />
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
<style>{`
|
|
74
|
+
@keyframes hoverCardIn {
|
|
75
|
+
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
|
|
76
|
+
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
77
|
+
}
|
|
78
|
+
`}</style>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|