@putkoff/abstract-securefiles 0.0.47 → 0.0.48

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.
@@ -14,12 +14,49 @@ import {
14
14
  } from './../imports';
15
15
  import type { QueuedFile, UploadProps } from './../imports';
16
16
 
17
+ // ── helpers ─────────────────────────────────────────────────────────────────
18
+
19
+ /** Strip filename from a webkitRelativePath, keep the trailing slash. */
20
+ function deriveRelPath(f: File): string {
21
+ const wrp = (f as any).webkitRelativePath as string | undefined;
22
+ if (!wrp) return '';
23
+ const idx = wrp.lastIndexOf('/');
24
+ return idx >= 0 ? wrp.slice(0, idx + 1) : '';
25
+ }
26
+
27
+ /**
28
+ * Drain a DirectoryReader completely. The browser only returns ~100 entries
29
+ * per readEntries() call; you must call it repeatedly until it returns [].
30
+ */
31
+ function readAllEntries(reader: any): Promise<any[]> {
32
+ return new Promise((resolve, reject) => {
33
+ const all: any[] = [];
34
+ const pump = () => {
35
+ reader.readEntries(
36
+ (batch: any[]) => {
37
+ if (batch.length === 0) resolve(all);
38
+ else { all.push(...batch); pump(); }
39
+ },
40
+ reject,
41
+ );
42
+ };
43
+ pump();
44
+ });
45
+ }
46
+
47
+ // ── component ───────────────────────────────────────────────────────────────
48
+
17
49
  export default function Upload({ onUploaded }: UploadProps) {
18
50
  const [queue, setQueue] = useState<QueuedFile[]>([]);
19
51
  const [busy, setBusy] = useState(false);
20
52
  const [message, setMessage] = useState<string | null>(null);
21
53
  const [drag, setDrag] = useState(false);
22
- const inputRef = useRef<HTMLInputElement>(null);
54
+
55
+ // Two refs: one for plain file picking, one for folder picking.
56
+ const fileInputRef = useRef<HTMLInputElement>(null);
57
+ const dirInputRef = useRef<HTMLInputElement>(null);
58
+ const [pickMode, setPickMode] = useState<'files' | 'folder'>('files');
59
+
23
60
  const [dirs, setDirs] = useState<string[]>([]);
24
61
 
25
62
  // — existing files → directory list —
@@ -43,61 +80,95 @@ export default function Upload({ onUploaded }: UploadProps) {
43
80
  const [newDirName, setNewDirName] = useState('');
44
81
 
45
82
  // — queue logic —
46
- const enqueue = useCallback((files: QueuedFile[]) => {
83
+ const enqueue = useCallback((incoming: QueuedFile[]) => {
47
84
  setQueue(prev => {
48
85
  const map = new Map(prev.map(q => [`${q.file.name}-${q.file.size}`, q]));
49
- files.forEach(q => map.set(`${q.file.name}-${q.file.size}`, q));
86
+ incoming.forEach(q => map.set(`${q.file.name}-${q.file.size}`, q));
50
87
  return Array.from(map.values());
51
88
  });
52
89
  }, []);
53
90
 
54
91
  const extractFromDataTransfer = useCallback(
55
92
  async (dt: DataTransfer): Promise<QueuedFile[]> => {
93
+ const items = Array.from(dt.items ?? []);
94
+ const hasEntryApi =
95
+ items.length > 0 &&
96
+ typeof (items[0] as any).webkitGetAsEntry === 'function';
97
+
98
+ // Fallback: no entry API → flat files only, no folder traversal.
99
+ if (!hasEntryApi) {
100
+ return Array.from(dt.files ?? []).map(file => ({
101
+ file,
102
+ relPath: '',
103
+ progress: 0,
104
+ }));
105
+ }
106
+
56
107
  const out: QueuedFile[] = [];
57
- const walk = async (entry: any, path = "") => {
108
+
109
+ const walk = async (entry: any, path = ''): Promise<void> => {
58
110
  if (!entry) return;
59
111
  if (entry.isFile) {
60
- const file: File = await new Promise(res => entry.file(res));
112
+ const file: File = await new Promise((res, rej) =>
113
+ entry.file(res, rej),
114
+ );
61
115
  out.push({ file, relPath: path, progress: 0 });
62
- } else if (entry.isDirectory) {
116
+ return;
117
+ }
118
+ if (entry.isDirectory) {
63
119
  const reader = entry.createReader();
64
- const entries: any[] = await new Promise(r => reader.readEntries(r));
65
- await Promise.all(entries.map(e => walk(e, `${path}${entry.name}/`)));
120
+ const entries = await readAllEntries(reader);
121
+ await Promise.all(
122
+ entries.map(e => walk(e, `${path}${entry.name}/`)),
123
+ );
66
124
  }
67
125
  };
68
- await Promise.all(Array.from(dt.items).map(i => walk(i.webkitGetAsEntry())));
126
+
127
+ await Promise.all(
128
+ items.map(i => walk((i as any).webkitGetAsEntry?.())),
129
+ );
69
130
  return out;
70
131
  },
71
132
  []
72
133
  );
73
134
 
74
135
  const handleSelect = (e: ChangeEvent<HTMLInputElement>) => {
75
- const files = Array.from(e.target.files || []);
76
- const queued: any = files.map(f => ({
136
+ const picked = Array.from(e.target.files ?? []);
137
+ const queued: QueuedFile[] = picked.map(f => ({
77
138
  file: f,
78
- relPath: (f as any).webkitRelativePath.replace(/[^/]*$/, ""),
139
+ relPath: deriveRelPath(f),
79
140
  progress: 0,
80
141
  }));
81
142
  enqueue(queued);
82
- e.target.value = "";
143
+ e.target.value = '';
83
144
  };
84
145
 
85
146
  const handleDrop = async (e: React.DragEvent) => {
86
147
  e.preventDefault();
87
- const files = await extractFromDataTransfer(e.dataTransfer);
88
- enqueue(files.length ? files : []);
148
+ e.stopPropagation();
149
+ const dropped = await extractFromDataTransfer(e.dataTransfer);
150
+ if (dropped.length) enqueue(dropped);
89
151
  setDrag(false);
90
152
  };
91
153
 
154
+ const openPicker = (e: React.MouseEvent) => {
155
+ // Only fire when the dropzone background itself is clicked, not children.
156
+ if (e.target !== e.currentTarget && !(e.target as HTMLElement).closest('[data-dz-passthrough]')) {
157
+ // children opt in via data-dz-passthrough if they should still open the picker
158
+ }
159
+ const ref = pickMode === 'folder' ? dirInputRef : fileInputRef;
160
+ ref.current?.click();
161
+ };
162
+
92
163
  const handleUploadAll = async () => {
93
164
  if (!queue.length) {
94
- setMessage("No files queued.");
165
+ setMessage('No files queued.');
95
166
  return;
96
167
  }
97
168
  setBusy(true);
98
169
  setMessage(null);
99
170
  const targetDir = creatingNew
100
- ? (newDirName.trim().replace(/^\/+|\/+$/g, "") + "/")
171
+ ? (newDirName.trim().replace(/^\/+|\/+$/g, '') + '/')
101
172
  : uploadDir;
102
173
  try {
103
174
  for (const { file, relPath } of queue) {
@@ -109,11 +180,11 @@ export default function Upload({ onUploaded }: UploadProps) {
109
180
  );
110
181
  }
111
182
  setQueue([]);
112
- await onUploaded();
113
- setMessage("Upload complete");
114
- load();
183
+ await onUploaded(); // SecureFiles passes filesHook.load here
184
+ setMessage('Upload complete');
185
+ // (removed the duplicate load() — onUploaded already does it)
115
186
  } catch {
116
- setMessage("Upload failed");
187
+ setMessage('Upload failed');
117
188
  } finally {
118
189
  setBusy(false);
119
190
  }
@@ -122,25 +193,22 @@ export default function Upload({ onUploaded }: UploadProps) {
122
193
  // — directory selector handlers —
123
194
  const handleDirChange = (e: ChangeEvent<HTMLSelectElement>) => {
124
195
  const v = e.target.value;
125
- if (v === "__new__") {
196
+ if (v === '__new__') {
126
197
  setCreatingNew(true);
127
- setNewDirName("");
198
+ setNewDirName('');
128
199
  } else {
129
200
  setCreatingNew(false);
130
201
  setUploadDir(v);
131
202
  }
132
203
  };
133
204
  const acceptNewDir = () => {
134
- // normalize like before
135
205
  const cleanName = newDirName.trim().replace(/^\/+|\/+$/g, '');
206
+ if (!cleanName) {
207
+ setCreatingNew(false);
208
+ return;
209
+ }
136
210
  const normalized = `${uploadDir}${cleanName}/`;
137
- // ① add it to our dirs list if it isn't there already
138
- setDirs(prev => {
139
- if (prev.includes(normalized)) return prev;
140
- return [...prev, normalized].sort();
141
- });
142
-
143
- // ② select it & flip back to the <select>
211
+ setDirs(prev => prev.includes(normalized) ? prev : [...prev, normalized].sort());
144
212
  setUploadDir(normalized);
145
213
  setCreatingNew(false);
146
214
  setNewDirName('');
@@ -148,14 +216,39 @@ export default function Upload({ onUploaded }: UploadProps) {
148
216
 
149
217
  return (
150
218
  <div className="form-container max-w-2xl mx-auto">
219
+ {/* pick-mode toggle */}
220
+ <div className="flex gap-4 text-xs text-gray-700 mb-1">
221
+ <label className="flex items-center gap-1">
222
+ <input
223
+ type="radio"
224
+ name="pick-mode"
225
+ checked={pickMode === 'files'}
226
+ onChange={() => setPickMode('files')}
227
+ />
228
+ Files
229
+ </label>
230
+ <label className="flex items-center gap-1">
231
+ <input
232
+ type="radio"
233
+ name="pick-mode"
234
+ checked={pickMode === 'folder'}
235
+ onChange={() => setPickMode('folder')}
236
+ />
237
+ Folder
238
+ </label>
239
+ <span className="text-gray-400">
240
+ (drag-and-drop accepts either)
241
+ </span>
242
+ </div>
243
+
151
244
  {/* drop area */}
152
245
  <div
153
246
  className={`flex items-center border-2 border-dashed rounded-lg p-6 bg-white shadow-md space-x-4 transition-shadow duration-150
154
247
  ${drag ? 'border-indigo-500 bg-indigo-50 shadow-lg' : 'border-gray-300 hover:shadow-lg'}`}
155
- onClick={() => inputRef.current?.click()}
156
- onDragEnter={e => { e.preventDefault(); setDrag(true); }}
157
- onDragOver={e => e.preventDefault()}
158
- onDragLeave={() => setDrag(false)}
248
+ onClick={openPicker}
249
+ onDragEnter={e => { e.preventDefault(); e.stopPropagation(); setDrag(true); }}
250
+ onDragOver={e => { e.preventDefault(); e.stopPropagation(); }}
251
+ onDragLeave={e => { e.preventDefault(); e.stopPropagation(); setDrag(false); }}
159
252
  onDrop={handleDrop}
160
253
  role="region"
161
254
  aria-label="File upload area"
@@ -169,6 +262,9 @@ export default function Upload({ onUploaded }: UploadProps) {
169
262
  <p className="text-base font-semibold text-gray-700">
170
263
  Drag & drop files or folders here
171
264
  </p>
265
+ <p className="text-xs text-gray-500 mt-1">
266
+ Click to {pickMode === 'folder' ? 'choose a folder' : 'choose files'}
267
+ </p>
172
268
  {queue.length > 0 && (
173
269
  <p className="mt-1 text-sm text-gray-600">
174
270
  {queue.length} item{queue.length > 1 && 's'} queued
@@ -177,19 +273,28 @@ export default function Upload({ onUploaded }: UploadProps) {
177
273
  </div>
178
274
  </div>
179
275
 
180
- {/* hidden file input */}
276
+ {/* hidden file input — plain multi-file pick, works everywhere */}
181
277
  <input
182
- ref={inputRef}
278
+ ref={fileInputRef}
183
279
  type="file"
184
280
  multiple
185
281
  className="hidden"
186
- {...({ webkitdirectory: true, directory: '' } as any)}
282
+ onChange={handleSelect}
283
+ />
284
+
285
+ {/* hidden directory input — Chromium, Safari 11.1+, Firefox 50+ */}
286
+ <input
287
+ ref={dirInputRef}
288
+ type="file"
289
+ multiple
290
+ className="hidden"
291
+ // non-standard attributes; both forms keep older Firefox happy
292
+ {...({ webkitdirectory: '', directory: '' } as any)}
187
293
  onChange={handleSelect}
188
294
  />
189
295
 
190
296
  {/* bottom controls */}
191
297
  <div className="flex items-center justify-between mt-4">
192
- {/* directory selector / new dir input */}
193
298
  <div className="flex-1 mr-4">
194
299
  {!creatingNew ? (
195
300
  <select
@@ -220,7 +325,6 @@ export default function Upload({ onUploaded }: UploadProps) {
220
325
  )}
221
326
  </div>
222
327
 
223
- {/* upload button */}
224
328
  <Button
225
329
  color="blue"
226
330
  variant="primary"
@@ -233,15 +337,13 @@ export default function Upload({ onUploaded }: UploadProps) {
233
337
 
234
338
  {message && (
235
339
  <p
236
- className={`text-center text-sm ${message.includes('failed') ? 'text-red-600' : 'text-gray-600'
237
- }`}
340
+ className={`text-center text-sm ${message.includes('failed') ? 'text-red-600' : 'text-gray-600'}`}
238
341
  role="alert"
239
342
  >
240
343
  {message}
241
344
  </p>
242
345
  )}
243
346
 
244
- {/* queued list */}
245
347
  {queue.length > 0 && (
246
348
  <div className="wm-container mt-4">
247
349
  <h3 className="mb-2 text-sm font-semibold text-gray-700">Queued Files</h3>
@@ -264,4 +366,4 @@ export default function Upload({ onUploaded }: UploadProps) {
264
366
  )}
265
367
  </div>
266
368
  );
267
- }
369
+ }