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/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 &amp; 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
+ }