cynx-ui 1.2.43 → 1.2.44

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.
@@ -1,28 +1,202 @@
1
1
  import { useState, useRef } from 'react';
2
+ import { UploadCloud, Paperclip, FileCheck, X, Eye, Trash2 } from 'lucide-react';
3
+ import LineProgress from './LineProgress.jsx';
4
+ import Badge from './Badge.jsx';
5
+ import Button from './Button.jsx';
2
6
 
3
- export function Dropzone({ onFile, accept, hint, file }) {
7
+ function formatBytes(bytes, decimals = 1) {
8
+ if (!bytes || bytes === 0) return '0 B';
9
+ const k = 1024;
10
+ const dm = decimals < 0 ? 0 : decimals;
11
+ const sizes = ['B', 'KB', 'MB', 'GB'];
12
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
13
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
14
+ }
15
+
16
+ export function Dropzone({
17
+ onFiles,
18
+ onFile,
19
+ accept = 'image/*,application/pdf,.*',
20
+ multiple = true,
21
+ hint = 'Поддерживаются изображения, PDF документы и любые файлы',
22
+ uploadingFiles = [],
23
+ existingFiles = [],
24
+ onRemoveUploading,
25
+ onRemoveExisting,
26
+ onPreview,
27
+ label = 'Прикрепить файлы (Фото, PDF, документы)',
28
+ }) {
4
29
  const [over, setOver] = useState(false);
5
30
  const inputRef = useRef(null);
31
+
32
+ const handleFiles = (fileList) => {
33
+ const arr = Array.from(fileList || []);
34
+ if (arr.length === 0) return;
35
+ if (onFiles) onFiles(arr);
36
+ else if (onFile) arr.forEach(f => onFile(f));
37
+ };
38
+
39
+ const handleDrop = (e) => {
40
+ e.preventDefault();
41
+ e.stopPropagation();
42
+ setOver(false);
43
+ if (e.dataTransfer && e.dataTransfer.files) {
44
+ handleFiles(e.dataTransfer.files);
45
+ }
46
+ };
47
+
6
48
  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>
49
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
50
+ {label && (
51
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--t2)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block' }}>
52
+ {label}
53
+ </label>
54
+ )}
55
+
56
+ <div
57
+ className={`dropzone${over ? ' over' : ''}`}
58
+ onDragOver={e => { e.preventDefault(); e.stopPropagation(); setOver(true); }}
59
+ onDragLeave={e => { e.preventDefault(); e.stopPropagation(); setOver(false); }}
60
+ onDrop={handleDrop}
61
+ onClick={() => inputRef.current?.click()}
62
+ style={{
63
+ display: 'flex',
64
+ flexDirection: 'column',
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ padding: '20px 20px',
68
+ border: `2px dashed ${over ? 'var(--accent, #3b82f6)' : 'var(--border-light)'}`,
69
+ borderRadius: 'var(--rad)',
70
+ cursor: 'pointer',
71
+ background: over ? 'rgba(59, 130, 246, 0.08)' : 'var(--grey-1)',
72
+ transition: 'all .2s ease',
73
+ textAlign: 'center',
74
+ }}
75
+ >
76
+ <input
77
+ ref={inputRef}
78
+ type="file"
79
+ accept={accept}
80
+ multiple={multiple}
81
+ onChange={e => {
82
+ handleFiles(e.target.files);
83
+ e.target.value = '';
84
+ }}
85
+ style={{ display: 'none' }}
86
+ />
87
+ <UploadCloud size={24} color="var(--accent, #3b82f6)" style={{ marginBottom: 6, pointerEvents: 'none' }} />
88
+ <span style={{ fontSize: '.82rem', fontWeight: 600, pointerEvents: 'none' }}>
89
+ Выберите файлы, перетащите их сюда или вставьте через Ctrl+V
90
+ </span>
91
+ {hint && <span style={{ fontSize: '.72rem', color: 'var(--muted)', marginTop: 2, pointerEvents: 'none' }}>{hint}</span>}
18
92
  </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
- </>
93
+
94
+ {/* Uploading progress items */}
95
+ {uploadingFiles.length > 0 && (
96
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
97
+ {uploadingFiles.map((item) => (
98
+ <div
99
+ key={item.tempId || item.name}
100
+ style={{
101
+ display: 'flex',
102
+ flexDirection: 'column',
103
+ gap: 4,
104
+ padding: '8px 10px',
105
+ background: 'var(--surface)',
106
+ border: '1px solid var(--border-light)',
107
+ borderRadius: 'var(--rads)',
108
+ fontSize: '.8rem',
109
+ }}
110
+ >
111
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
112
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
113
+ {item.status === 'uploading' && <Paperclip size={14} color="var(--accent)" />}
114
+ {item.status === 'done' && <FileCheck size={14} color="var(--success, #22c55e)" />}
115
+ {item.status === 'error' && <X size={14} color="var(--err, #ef4444)" />}
116
+ <span style={{ textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', fontWeight: 500 }}>
117
+ {item.name} <span style={{ color: 'var(--muted)', fontSize: '.72rem' }}>({formatBytes(item.size)})</span>
118
+ </span>
119
+ </div>
120
+
121
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
122
+ {item.status === 'uploading' && (
123
+ <span style={{ fontSize: '.75rem', fontWeight: 600, color: 'var(--accent, #3b82f6)' }}>
124
+ {item.progress}%
125
+ </span>
126
+ )}
127
+ {item.status === 'done' && <Badge variant="ok" size="xs">Загружен</Badge>}
128
+ {item.status === 'error' && <Badge variant="err" size="xs">{item.error || 'Ошибка'}</Badge>}
129
+ {onRemoveUploading && (
130
+ <button
131
+ onClick={(e) => { e.stopPropagation(); onRemoveUploading(item.tempId); }}
132
+ style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 2, color: 'var(--muted)' }}
133
+ >
134
+ <X size={14} />
135
+ </button>
136
+ )}
137
+ </div>
138
+ </div>
139
+
140
+ {item.status === 'uploading' && (
141
+ <LineProgress
142
+ value={item.progress}
143
+ color="var(--accent, #3b82f6)"
144
+ height={4}
145
+ showPercent={false}
146
+ style={{ marginTop: 2 }}
147
+ />
148
+ )}
149
+ </div>
150
+ ))}
151
+ </div>
152
+ )}
153
+
154
+ {/* Existing saved files list */}
155
+ {existingFiles.length > 0 && (
156
+ <div style={{ marginTop: 4 }}>
157
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
158
+ {existingFiles.map((f) => (
159
+ <div
160
+ key={f.id}
161
+ style={{
162
+ display: 'flex',
163
+ alignItems: 'center',
164
+ justifyContent: 'space-between',
165
+ padding: '6px 10px',
166
+ background: 'var(--surface)',
167
+ border: '1px solid var(--border-light)',
168
+ borderRadius: 'var(--rads)',
169
+ fontSize: '.8rem',
170
+ }}
171
+ >
172
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
173
+ <Paperclip size={14} color="var(--accent)" />
174
+ <span style={{ textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', fontWeight: 500 }}>
175
+ {f.original_name || f.originalName} <span style={{ color: 'var(--muted)', fontSize: '.72rem' }}>({formatBytes(f.file_size || f.fileSize)})</span>
176
+ </span>
177
+ </div>
178
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
179
+ {onPreview && (
180
+ <Button
181
+ variant="ghost"
182
+ size="xs"
183
+ icon={<Eye size={13} />}
184
+ onClick={(e) => { e.stopPropagation(); onPreview(f); }}
185
+ />
186
+ )}
187
+ {onRemoveExisting && (
188
+ <Button
189
+ variant="ghost"
190
+ size="xs"
191
+ icon={<Trash2 size={13} color="var(--err)" />}
192
+ onClick={(e) => { e.stopPropagation(); onRemoveExisting(f.id); }}
193
+ />
194
+ )}
195
+ </div>
196
+ </div>
197
+ ))}
198
+ </div>
199
+ </div>
26
200
  )}
27
201
  </div>
28
202
  );
@@ -0,0 +1,205 @@
1
+ import { useState, useRef } from 'react';
2
+ import { UploadCloud, Paperclip, FileCheck, X, Eye, Trash2 } from 'lucide-react';
3
+ import LineProgress from './LineProgress.jsx';
4
+ import Badge from './Badge.jsx';
5
+ import Button from './Button.jsx';
6
+
7
+ function formatBytes(bytes, decimals = 1) {
8
+ if (!bytes || bytes === 0) return '0 B';
9
+ const k = 1024;
10
+ const dm = decimals < 0 ? 0 : decimals;
11
+ const sizes = ['B', 'KB', 'MB', 'GB'];
12
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
13
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
14
+ }
15
+
16
+ export function FilePicker({
17
+ onFiles,
18
+ onFile,
19
+ accept = 'image/*,application/pdf,.*',
20
+ multiple = true,
21
+ hint = 'Поддерживаются изображения, PDF документы и любые файлы',
22
+ uploadingFiles = [],
23
+ existingFiles = [],
24
+ onRemoveUploading,
25
+ onRemoveExisting,
26
+ onPreview,
27
+ label = 'Прикрепить файлы (Фото, PDF, документы)',
28
+ }) {
29
+ const [over, setOver] = useState(false);
30
+ const inputRef = useRef(null);
31
+
32
+ const handleFiles = (fileList) => {
33
+ const arr = Array.from(fileList || []);
34
+ if (arr.length === 0) return;
35
+ if (onFiles) onFiles(arr);
36
+ else if (onFile) arr.forEach(f => onFile(f));
37
+ };
38
+
39
+ const handleDrop = (e) => {
40
+ e.preventDefault();
41
+ e.stopPropagation();
42
+ setOver(false);
43
+ if (e.dataTransfer && e.dataTransfer.files) {
44
+ handleFiles(e.dataTransfer.files);
45
+ }
46
+ };
47
+
48
+ return (
49
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
50
+ {label && (
51
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--t2)', textTransform: 'uppercase', letterSpacing: '.08em', display: 'block' }}>
52
+ {label}
53
+ </label>
54
+ )}
55
+
56
+ <div
57
+ className={`file-picker${over ? ' over' : ''}`}
58
+ onDragOver={e => { e.preventDefault(); e.stopPropagation(); setOver(true); }}
59
+ onDragLeave={e => { e.preventDefault(); e.stopPropagation(); setOver(false); }}
60
+ onDrop={handleDrop}
61
+ onClick={() => inputRef.current?.click()}
62
+ style={{
63
+ display: 'flex',
64
+ flexDirection: 'column',
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ padding: '20px 20px',
68
+ border: `2px dashed ${over ? 'var(--accent, #3b82f6)' : 'var(--border-light)'}`,
69
+ borderRadius: 'var(--rad)',
70
+ cursor: 'pointer',
71
+ background: over ? 'rgba(59, 130, 246, 0.08)' : 'var(--grey-1)',
72
+ transition: 'all .2s ease',
73
+ textAlign: 'center',
74
+ }}
75
+ >
76
+ <input
77
+ ref={inputRef}
78
+ type="file"
79
+ accept={accept}
80
+ multiple={multiple}
81
+ onChange={e => {
82
+ handleFiles(e.target.files);
83
+ e.target.value = '';
84
+ }}
85
+ style={{ display: 'none' }}
86
+ />
87
+ <UploadCloud size={24} color="var(--accent, #3b82f6)" style={{ marginBottom: 6, pointerEvents: 'none' }} />
88
+ <span style={{ fontSize: '.82rem', fontWeight: 600, pointerEvents: 'none' }}>
89
+ Выберите файлы, перетащите их сюда или вставьте через Ctrl+V
90
+ </span>
91
+ {hint && <span style={{ fontSize: '.72rem', color: 'var(--muted)', marginTop: 2, pointerEvents: 'none' }}>{hint}</span>}
92
+ </div>
93
+
94
+ {/* Uploading progress items */}
95
+ {uploadingFiles.length > 0 && (
96
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
97
+ {uploadingFiles.map((item) => (
98
+ <div
99
+ key={item.tempId || item.name}
100
+ style={{
101
+ display: 'flex',
102
+ flexDirection: 'column',
103
+ gap: 4,
104
+ padding: '8px 10px',
105
+ background: 'var(--surface)',
106
+ border: '1px solid var(--border-light)',
107
+ borderRadius: 'var(--rads)',
108
+ fontSize: '.8rem',
109
+ }}
110
+ >
111
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
112
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
113
+ {item.status === 'uploading' && <Paperclip size={14} color="var(--accent)" />}
114
+ {item.status === 'done' && <FileCheck size={14} color="var(--success, #22c55e)" />}
115
+ {item.status === 'error' && <X size={14} color="var(--err, #ef4444)" />}
116
+ <span style={{ textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', fontWeight: 500 }}>
117
+ {item.name} <span style={{ color: 'var(--muted)', fontSize: '.72rem' }}>({formatBytes(item.size)})</span>
118
+ </span>
119
+ </div>
120
+
121
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
122
+ {item.status === 'uploading' && (
123
+ <span style={{ fontSize: '.75rem', fontWeight: 600, color: 'var(--accent, #3b82f6)' }}>
124
+ {item.progress}%
125
+ </span>
126
+ )}
127
+ {item.status === 'done' && <Badge variant="ok" size="xs">Загружен</Badge>}
128
+ {item.status === 'error' && <Badge variant="err" size="xs">{item.error || 'Ошибка'}</Badge>}
129
+ {onRemoveUploading && (
130
+ <button
131
+ onClick={(e) => { e.stopPropagation(); onRemoveUploading(item.tempId); }}
132
+ style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 2, color: 'var(--muted)' }}
133
+ >
134
+ <X size={14} />
135
+ </button>
136
+ )}
137
+ </div>
138
+ </div>
139
+
140
+ {item.status === 'uploading' && (
141
+ <LineProgress
142
+ value={item.progress}
143
+ color="var(--accent, #3b82f6)"
144
+ height={4}
145
+ showPercent={false}
146
+ style={{ marginTop: 2 }}
147
+ />
148
+ )}
149
+ </div>
150
+ ))}
151
+ </div>
152
+ )}
153
+
154
+ {/* Existing saved files list */}
155
+ {existingFiles.length > 0 && (
156
+ <div style={{ marginTop: 4 }}>
157
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
158
+ {existingFiles.map((f) => (
159
+ <div
160
+ key={f.id}
161
+ style={{
162
+ display: 'flex',
163
+ alignItems: 'center',
164
+ justifyContent: 'space-between',
165
+ padding: '6px 10px',
166
+ background: 'var(--surface)',
167
+ border: '1px solid var(--border-light)',
168
+ borderRadius: 'var(--rads)',
169
+ fontSize: '.8rem',
170
+ }}
171
+ >
172
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
173
+ <Paperclip size={14} color="var(--accent)" />
174
+ <span style={{ textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', fontWeight: 500 }}>
175
+ {f.original_name || f.originalName} <span style={{ color: 'var(--muted)', fontSize: '.72rem' }}>({formatBytes(f.file_size || f.fileSize)})</span>
176
+ </span>
177
+ </div>
178
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
179
+ {onPreview && (
180
+ <Button
181
+ variant="ghost"
182
+ size="xs"
183
+ icon={<Eye size={13} />}
184
+ onClick={(e) => { e.stopPropagation(); onPreview(f); }}
185
+ />
186
+ )}
187
+ {onRemoveExisting && (
188
+ <Button
189
+ variant="ghost"
190
+ size="xs"
191
+ icon={<Trash2 size={13} color="var(--err)" />}
192
+ onClick={(e) => { e.stopPropagation(); onRemoveExisting(f.id); }}
193
+ />
194
+ )}
195
+ </div>
196
+ </div>
197
+ ))}
198
+ </div>
199
+ </div>
200
+ )}
201
+ </div>
202
+ );
203
+ }
204
+
205
+ export default FilePicker;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cynx-ui",
3
- "version": "1.2.43",
3
+ "version": "1.2.44",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.js"