@putkoff/abstract-securefiles 0.0.45 → 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.
package/dist/index.js CHANGED
@@ -2,8 +2,7 @@ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
2
  import { createPortal } from 'react-dom';
3
3
  import * as React from 'react';
4
4
  import { useCallback, useState, useEffect, useRef, useMemo } from 'react';
5
- import { requireToken, alertit, currentUsername } from '@putkoff/abstract-utilities';
6
- import { secureFetchIt } from '@putkoff/abstract-apis';
5
+ import { alertit, currentUsername } from '@putkoff/abstract-utilities';
7
6
 
8
7
  function ClipboardIcon({
9
8
  title,
@@ -164,110 +163,6 @@ async function downloadFile(item) {
164
163
  triggerDownload(blob, item.filename || "download");
165
164
  }
166
165
 
167
- async function downloadSelect(item) {
168
- const token = requireToken();
169
- const body = item;
170
- const headers = {
171
- Authorization: `Bearer ${token}`,
172
- Accept: "application/octet-stream",
173
- "Content-Type": "application/json"
174
- };
175
- const blob = await secureFetchIt("download", body, "POST", headers, true, true);
176
- if (!blob || blob.size === 0) {
177
- throw new Error(`Received empty file data for “${item.filename}”`);
178
- }
179
- triggerDownload(blob, item.filename);
180
- }
181
-
182
- /** Given a checkbox inside a table-row → download that row’s file. */
183
- async function downloadRow(cb) {
184
- const row = cb.closest("tr");
185
- const filepath = cb.value;
186
- const filename = row?.children[1]?.textContent?.trim() || "downloaded_file";
187
- if (filepath)
188
- await downloadSelect({ filepath, filename });
189
- }
190
- /** Bulk download: pass a NodeList or array of checkboxes. */
191
- async function downloadSelected(checkboxes) {
192
- const list = Array.from(checkboxes);
193
- if (!list.length) {
194
- alert("No files selected.");
195
- return;
196
- }
197
- for (const cb of list) {
198
- await downloadRow(cb);
199
- }
200
- }
201
-
202
- /**
203
- * Classic “select all rows” logic (still handy in non-React views
204
- * such as the admin dashboard).
205
- */
206
- function syncHeaderCheckbox() {
207
- const all = document.querySelectorAll(".file-checkbox");
208
- const checked = document.querySelectorAll(".file-checkbox:checked");
209
- const header = document.getElementById("select-all-checkbox");
210
- header.indeterminate =
211
- checked.length > 0 && checked.length < all.length;
212
- header.checked = checked.length === all.length;
213
- }
214
-
215
- /** Wire up header + row checkboxes once the table is in the DOM. */
216
- function initSelectAll() {
217
- const header = document.getElementById("select-all-checkbox");
218
- const btn = document.getElementById("select-all");
219
- // 1) header → toggle all
220
- header.addEventListener("change", (e) => {
221
- const on = e.target.checked;
222
- document
223
- .querySelectorAll(".file-checkbox")
224
- .forEach((cb) => (cb.checked = on));
225
- });
226
- // 2) button → toggle all
227
- btn.addEventListener("click", () => {
228
- const cbs = Array.from(document.querySelectorAll(".file-checkbox"));
229
- if (!cbs.length) {
230
- alert("No files to select.");
231
- return;
232
- }
233
- const anyUnchecked = cbs.some((cb) => !cb.checked);
234
- cbs.forEach((cb) => (cb.checked = anyUnchecked));
235
- syncHeaderCheckbox();
236
- });
237
- // 3) row boxes → keep header state
238
- document
239
- .querySelectorAll(".file-checkbox")
240
- .forEach((cb) => cb.addEventListener("change", syncHeaderCheckbox));
241
- syncHeaderCheckbox();
242
- }
243
-
244
- /** Utility for other screens: toggle a list of checkboxes on/off. */
245
- function toggleCheckboxes(cbs) {
246
- if (!cbs.length) {
247
- alert("No files to select.");
248
- return;
249
- }
250
- const anyUnchecked = cbs.some((cb) => !cb.checked);
251
- cbs.forEach((cb) => (cb.checked = anyUnchecked));
252
- syncHeaderCheckbox();
253
- }
254
-
255
- const API_BASE$1 = '/api/secure-files';
256
- /** Generic PATCH helper for file-level admin settings. */
257
- async function patchFileSetting(fileId, field, value) {
258
- const res = await fetch(`${API_BASE$1}/files/${fileId}/settings/${field}`, {
259
- method: 'PATCH',
260
- credentials: 'include',
261
- headers: { 'Content-Type': 'application/json' },
262
- body: JSON.stringify({ value }),
263
- });
264
- if (!res.ok) {
265
- const err = await res.json().catch(() => ({}));
266
- throw new Error(err.error || res.statusText);
267
- }
268
- return (await res.json());
269
- }
270
-
271
166
  /**
272
167
  * Cookie-authenticated API helpers.
273
168
  * All requests include credentials: 'include' so the browser sends the
@@ -304,10 +199,10 @@ function resolveApiBase() {
304
199
  // 4. Same-origin default
305
200
  return "/api/secure-files";
306
201
  }
307
- const API_BASE = resolveApiBase();
202
+ const API_BASE$1 = resolveApiBase();
308
203
  function apiUrl(path) {
309
204
  const p = path.startsWith("/") ? path : `/${path}`;
310
- return `${API_BASE}${p}`;
205
+ return `${API_BASE$1}${p}`;
311
206
  }
312
207
  /**
313
208
  * Generic JSON fetch with session-cookie auth.
@@ -368,6 +263,118 @@ async function cookieUpload(file, relPath = "") {
368
263
  throw new Error(`Upload failed: HTTP ${res.status}`);
369
264
  return res.json();
370
265
  }
266
+ /** The resolved API base URL, exported for debugging / display purposes. */
267
+ const SECURE_FILES_API_BASE = API_BASE$1;
268
+
269
+ async function downloadSelect(item) {
270
+ const res = await fetch(`${SECURE_FILES_API_BASE}/download`, {
271
+ method: "POST",
272
+ credentials: "include",
273
+ headers: {
274
+ "Content-Type": "application/json",
275
+ Accept: "application/octet-stream",
276
+ },
277
+ body: JSON.stringify(item),
278
+ });
279
+ if (res.status === 401)
280
+ throw new Error("Not authenticated – please log in.");
281
+ if (!res.ok)
282
+ throw new Error(`Download failed: HTTP ${res.status}`);
283
+ const blob = await res.blob();
284
+ if (!blob || blob.size === 0) {
285
+ throw new Error(`Received empty file data for "${item.filename}"`);
286
+ }
287
+ triggerDownload(blob, item.filename);
288
+ }
289
+
290
+ /** Given a checkbox inside a table-row → download that row’s file. */
291
+ async function downloadRow(cb) {
292
+ const row = cb.closest("tr");
293
+ const filepath = cb.value;
294
+ const filename = row?.children[1]?.textContent?.trim() || "downloaded_file";
295
+ if (filepath)
296
+ await downloadSelect({ filepath, filename });
297
+ }
298
+ /** Bulk download: pass a NodeList or array of checkboxes. */
299
+ async function downloadSelected(checkboxes) {
300
+ const list = Array.from(checkboxes);
301
+ if (!list.length) {
302
+ alert("No files selected.");
303
+ return;
304
+ }
305
+ for (const cb of list) {
306
+ await downloadRow(cb);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Classic “select all rows” logic (still handy in non-React views
312
+ * such as the admin dashboard).
313
+ */
314
+ function syncHeaderCheckbox() {
315
+ const all = document.querySelectorAll(".file-checkbox");
316
+ const checked = document.querySelectorAll(".file-checkbox:checked");
317
+ const header = document.getElementById("select-all-checkbox");
318
+ header.indeterminate =
319
+ checked.length > 0 && checked.length < all.length;
320
+ header.checked = checked.length === all.length;
321
+ }
322
+
323
+ /** Wire up header + row checkboxes once the table is in the DOM. */
324
+ function initSelectAll() {
325
+ const header = document.getElementById("select-all-checkbox");
326
+ const btn = document.getElementById("select-all");
327
+ // 1) header → toggle all
328
+ header.addEventListener("change", (e) => {
329
+ const on = e.target.checked;
330
+ document
331
+ .querySelectorAll(".file-checkbox")
332
+ .forEach((cb) => (cb.checked = on));
333
+ });
334
+ // 2) button → toggle all
335
+ btn.addEventListener("click", () => {
336
+ const cbs = Array.from(document.querySelectorAll(".file-checkbox"));
337
+ if (!cbs.length) {
338
+ alert("No files to select.");
339
+ return;
340
+ }
341
+ const anyUnchecked = cbs.some((cb) => !cb.checked);
342
+ cbs.forEach((cb) => (cb.checked = anyUnchecked));
343
+ syncHeaderCheckbox();
344
+ });
345
+ // 3) row boxes → keep header state
346
+ document
347
+ .querySelectorAll(".file-checkbox")
348
+ .forEach((cb) => cb.addEventListener("change", syncHeaderCheckbox));
349
+ syncHeaderCheckbox();
350
+ }
351
+
352
+ /** Utility for other screens: toggle a list of checkboxes on/off. */
353
+ function toggleCheckboxes(cbs) {
354
+ if (!cbs.length) {
355
+ alert("No files to select.");
356
+ return;
357
+ }
358
+ const anyUnchecked = cbs.some((cb) => !cb.checked);
359
+ cbs.forEach((cb) => (cb.checked = anyUnchecked));
360
+ syncHeaderCheckbox();
361
+ }
362
+
363
+ const API_BASE = '/api/secure-files';
364
+ /** Generic PATCH helper for file-level admin settings. */
365
+ async function patchFileSetting(fileId, field, value) {
366
+ const res = await fetch(`${API_BASE}/files/${fileId}/settings/${field}`, {
367
+ method: 'PATCH',
368
+ credentials: 'include',
369
+ headers: { 'Content-Type': 'application/json' },
370
+ body: JSON.stringify({ value }),
371
+ });
372
+ if (!res.ok) {
373
+ const err = await res.json().catch(() => ({}));
374
+ throw new Error(err.error || res.statusText);
375
+ }
376
+ return (await res.json());
377
+ }
371
378
 
372
379
  /** File-upload helper (used by non-React fallback pages). */
373
380
  async function uploadFile(file, subdir) {
@@ -837,12 +844,45 @@ function FileTable({ files, loading, error, onToggleShare, onPatchShare, onDelet
837
844
  } })] }));
838
845
  }
839
846
 
847
+ // ── helpers ─────────────────────────────────────────────────────────────────
848
+ /** Strip filename from a webkitRelativePath, keep the trailing slash. */
849
+ function deriveRelPath(f) {
850
+ const wrp = f.webkitRelativePath;
851
+ if (!wrp)
852
+ return '';
853
+ const idx = wrp.lastIndexOf('/');
854
+ return idx >= 0 ? wrp.slice(0, idx + 1) : '';
855
+ }
856
+ /**
857
+ * Drain a DirectoryReader completely. The browser only returns ~100 entries
858
+ * per readEntries() call; you must call it repeatedly until it returns [].
859
+ */
860
+ function readAllEntries(reader) {
861
+ return new Promise((resolve, reject) => {
862
+ const all = [];
863
+ const pump = () => {
864
+ reader.readEntries((batch) => {
865
+ if (batch.length === 0)
866
+ resolve(all);
867
+ else {
868
+ all.push(...batch);
869
+ pump();
870
+ }
871
+ }, reject);
872
+ };
873
+ pump();
874
+ });
875
+ }
876
+ // ── component ───────────────────────────────────────────────────────────────
840
877
  function Upload({ onUploaded }) {
841
878
  const [queue, setQueue] = useState([]);
842
879
  const [busy, setBusy] = useState(false);
843
880
  const [message, setMessage] = useState(null);
844
881
  const [drag, setDrag] = useState(false);
845
- const inputRef = useRef(null);
882
+ // Two refs: one for plain file picking, one for folder picking.
883
+ const fileInputRef = useRef(null);
884
+ const dirInputRef = useRef(null);
885
+ const [pickMode, setPickMode] = useState('files');
846
886
  const [dirs, setDirs] = useState([]);
847
887
  // — existing files → directory list —
848
888
  const { files, load } = useFiles();
@@ -862,56 +902,76 @@ function Upload({ onUploaded }) {
862
902
  const [creatingNew, setCreatingNew] = useState(false);
863
903
  const [newDirName, setNewDirName] = useState('');
864
904
  // — queue logic —
865
- const enqueue = useCallback((files) => {
905
+ const enqueue = useCallback((incoming) => {
866
906
  setQueue(prev => {
867
907
  const map = new Map(prev.map(q => [`${q.file.name}-${q.file.size}`, q]));
868
- files.forEach(q => map.set(`${q.file.name}-${q.file.size}`, q));
908
+ incoming.forEach(q => map.set(`${q.file.name}-${q.file.size}`, q));
869
909
  return Array.from(map.values());
870
910
  });
871
911
  }, []);
872
912
  const extractFromDataTransfer = useCallback(async (dt) => {
913
+ const items = Array.from(dt.items ?? []);
914
+ const hasEntryApi = items.length > 0 &&
915
+ typeof items[0].webkitGetAsEntry === 'function';
916
+ // Fallback: no entry API → flat files only, no folder traversal.
917
+ if (!hasEntryApi) {
918
+ return Array.from(dt.files ?? []).map(file => ({
919
+ file,
920
+ relPath: '',
921
+ progress: 0,
922
+ }));
923
+ }
873
924
  const out = [];
874
- const walk = async (entry, path = "") => {
925
+ const walk = async (entry, path = '') => {
875
926
  if (!entry)
876
927
  return;
877
928
  if (entry.isFile) {
878
- const file = await new Promise(res => entry.file(res));
929
+ const file = await new Promise((res, rej) => entry.file(res, rej));
879
930
  out.push({ file, relPath: path, progress: 0 });
931
+ return;
880
932
  }
881
- else if (entry.isDirectory) {
933
+ if (entry.isDirectory) {
882
934
  const reader = entry.createReader();
883
- const entries = await new Promise(r => reader.readEntries(r));
935
+ const entries = await readAllEntries(reader);
884
936
  await Promise.all(entries.map(e => walk(e, `${path}${entry.name}/`)));
885
937
  }
886
938
  };
887
- await Promise.all(Array.from(dt.items).map(i => walk(i.webkitGetAsEntry())));
939
+ await Promise.all(items.map(i => walk(i.webkitGetAsEntry?.())));
888
940
  return out;
889
941
  }, []);
890
942
  const handleSelect = (e) => {
891
- const files = Array.from(e.target.files || []);
892
- const queued = files.map(f => ({
943
+ const picked = Array.from(e.target.files ?? []);
944
+ const queued = picked.map(f => ({
893
945
  file: f,
894
- relPath: f.webkitRelativePath.replace(/[^/]*$/, ""),
946
+ relPath: deriveRelPath(f),
895
947
  progress: 0,
896
948
  }));
897
949
  enqueue(queued);
898
- e.target.value = "";
950
+ e.target.value = '';
899
951
  };
900
952
  const handleDrop = async (e) => {
901
953
  e.preventDefault();
902
- const files = await extractFromDataTransfer(e.dataTransfer);
903
- enqueue(files.length ? files : []);
954
+ e.stopPropagation();
955
+ const dropped = await extractFromDataTransfer(e.dataTransfer);
956
+ if (dropped.length)
957
+ enqueue(dropped);
904
958
  setDrag(false);
905
959
  };
960
+ const openPicker = (e) => {
961
+ // Only fire when the dropzone background itself is clicked, not children.
962
+ if (e.target !== e.currentTarget && !e.target.closest('[data-dz-passthrough]')) ;
963
+ const ref = pickMode === 'folder' ? dirInputRef : fileInputRef;
964
+ ref.current?.click();
965
+ };
906
966
  const handleUploadAll = async () => {
907
967
  if (!queue.length) {
908
- setMessage("No files queued.");
968
+ setMessage('No files queued.');
909
969
  return;
910
970
  }
911
971
  setBusy(true);
912
972
  setMessage(null);
913
973
  const targetDir = creatingNew
914
- ? (newDirName.trim().replace(/^\/+|\/+$/g, "") + "/")
974
+ ? (newDirName.trim().replace(/^\/+|\/+$/g, '') + '/')
915
975
  : uploadDir;
916
976
  try {
917
977
  for (const { file, relPath } of queue) {
@@ -919,12 +979,12 @@ function Upload({ onUploaded }) {
919
979
  setQueue(cur => cur.map(item => item.file === file ? { ...item, progress: 100 } : item));
920
980
  }
921
981
  setQueue([]);
922
- await onUploaded();
923
- setMessage("Upload complete");
924
- load();
982
+ await onUploaded(); // SecureFiles passes filesHook.load here
983
+ setMessage('Upload complete');
984
+ // (removed the duplicate load() — onUploaded already does it)
925
985
  }
926
986
  catch {
927
- setMessage("Upload failed");
987
+ setMessage('Upload failed');
928
988
  }
929
989
  finally {
930
990
  setBusy(false);
@@ -933,9 +993,9 @@ function Upload({ onUploaded }) {
933
993
  // — directory selector handlers —
934
994
  const handleDirChange = (e) => {
935
995
  const v = e.target.value;
936
- if (v === "__new__") {
996
+ if (v === '__new__') {
937
997
  setCreatingNew(true);
938
- setNewDirName("");
998
+ setNewDirName('');
939
999
  }
940
1000
  else {
941
1001
  setCreatingNew(false);
@@ -943,22 +1003,19 @@ function Upload({ onUploaded }) {
943
1003
  }
944
1004
  };
945
1005
  const acceptNewDir = () => {
946
- // normalize like before
947
1006
  const cleanName = newDirName.trim().replace(/^\/+|\/+$/g, '');
1007
+ if (!cleanName) {
1008
+ setCreatingNew(false);
1009
+ return;
1010
+ }
948
1011
  const normalized = `${uploadDir}${cleanName}/`;
949
- // ① add it to our dirs list if it isn't there already
950
- setDirs(prev => {
951
- if (prev.includes(normalized))
952
- return prev;
953
- return [...prev, normalized].sort();
954
- });
955
- // ② select it & flip back to the <select>
1012
+ setDirs(prev => prev.includes(normalized) ? prev : [...prev, normalized].sort());
956
1013
  setUploadDir(normalized);
957
1014
  setCreatingNew(false);
958
1015
  setNewDirName('');
959
1016
  };
960
- return (jsxs("div", { className: "form-container max-w-2xl mx-auto", children: [jsxs("div", { className: `flex items-center border-2 border-dashed rounded-lg p-6 bg-white shadow-md space-x-4 transition-shadow duration-150
961
- ${drag ? 'border-indigo-500 bg-indigo-50 shadow-lg' : 'border-gray-300 hover:shadow-lg'}`, onClick: () => inputRef.current?.click(), onDragEnter: e => { e.preventDefault(); setDrag(true); }, onDragOver: e => e.preventDefault(), onDragLeave: () => setDrag(false), onDrop: handleDrop, role: "region", "aria-label": "File upload area", children: [jsx(ForwardRef, { style: { width: 300, height: 300 }, className: "text-gray-500 mb-3", "aria-hidden": "true" }), jsxs("div", { className: "flex-1", children: [jsx("p", { className: "text-base font-semibold text-gray-700", children: "Drag & drop files or folders here" }), queue.length > 0 && (jsxs("p", { className: "mt-1 text-sm text-gray-600", children: [queue.length, " item", queue.length > 1 && 's', " queued"] }))] })] }), jsx("input", { ref: inputRef, type: "file", multiple: true, className: "hidden", ...{ webkitdirectory: true, directory: '' }, onChange: handleSelect }), jsxs("div", { className: "flex items-center justify-between mt-4", children: [jsx("div", { className: "flex-1 mr-4", children: !creatingNew ? (jsxs("select", { value: uploadDir, onChange: handleDirChange, className: "form-select w-full", children: [dirs.map(d => (jsx("option", { value: d, children: d }, d))), jsx("option", { value: "__new__", children: "+ Create new directory\u2026" })] })) : (jsxs("div", { className: "flex", children: [jsx("input", { type: "text", value: newDirName, placeholder: "new-folder-name", onChange: e => setNewDirName(e.target.value), onBlur: acceptNewDir, onKeyDown: e => e.key === 'Enter' && acceptNewDir(), className: "form-input flex-1" }), jsx(Button, { onClick: acceptNewDir, className: "ml-2", children: "OK" })] })) }), jsx(Button, { color: "blue", variant: "primary", onClick: handleUploadAll, disabled: busy || !queue.length, children: busy ? 'Uploading…' : `Upload ${queue.length}` })] }), message && (jsx("p", { className: `text-center text-sm ${message.includes('failed') ? 'text-red-600' : 'text-gray-600'}`, role: "alert", children: message })), queue.length > 0 && (jsxs("div", { className: "wm-container mt-4", children: [jsx("h3", { className: "mb-2 text-sm font-semibold text-gray-700", children: "Queued Files" }), jsx("ul", { className: "max-h-64 space-y-4 overflow-y-auto", children: queue.map(({ file, relPath, progress }) => (jsxs("li", { className: "space-y-1", children: [jsxs("div", { className: "flex justify-between items-center", children: [jsxs("span", { className: "truncate text-sm text-gray-800", children: [relPath, file.name] }), jsxs("span", { className: "text-xs text-gray-500", children: [progress, "%"] })] }), jsx("div", { className: "w-full bg-gray-200 rounded-full h-2 overflow-hidden", children: jsx("div", { className: "bg-indigo-600 h-2", style: { width: `${progress}%` } }) })] }, `${file.name}-${file.size}`))) })] }))] }));
1017
+ return (jsxs("div", { className: "form-container max-w-2xl mx-auto", children: [jsxs("div", { className: "flex gap-4 text-xs text-gray-700 mb-1", children: [jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "radio", name: "pick-mode", checked: pickMode === 'files', onChange: () => setPickMode('files') }), "Files"] }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "radio", name: "pick-mode", checked: pickMode === 'folder', onChange: () => setPickMode('folder') }), "Folder"] }), jsx("span", { className: "text-gray-400", children: "(drag-and-drop accepts either)" })] }), jsxs("div", { className: `flex items-center border-2 border-dashed rounded-lg p-6 bg-white shadow-md space-x-4 transition-shadow duration-150
1018
+ ${drag ? 'border-indigo-500 bg-indigo-50 shadow-lg' : 'border-gray-300 hover:shadow-lg'}`, onClick: openPicker, onDragEnter: e => { e.preventDefault(); e.stopPropagation(); setDrag(true); }, onDragOver: e => { e.preventDefault(); e.stopPropagation(); }, onDragLeave: e => { e.preventDefault(); e.stopPropagation(); setDrag(false); }, onDrop: handleDrop, role: "region", "aria-label": "File upload area", children: [jsx(ForwardRef, { style: { width: 300, height: 300 }, className: "text-gray-500 mb-3", "aria-hidden": "true" }), jsxs("div", { className: "flex-1", children: [jsx("p", { className: "text-base font-semibold text-gray-700", children: "Drag & drop files or folders here" }), jsxs("p", { className: "text-xs text-gray-500 mt-1", children: ["Click to ", pickMode === 'folder' ? 'choose a folder' : 'choose files'] }), queue.length > 0 && (jsxs("p", { className: "mt-1 text-sm text-gray-600", children: [queue.length, " item", queue.length > 1 && 's', " queued"] }))] })] }), jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: handleSelect }), jsx("input", { ref: dirInputRef, type: "file", multiple: true, className: "hidden", ...{ webkitdirectory: '', directory: '' }, onChange: handleSelect }), jsxs("div", { className: "flex items-center justify-between mt-4", children: [jsx("div", { className: "flex-1 mr-4", children: !creatingNew ? (jsxs("select", { value: uploadDir, onChange: handleDirChange, className: "form-select w-full", children: [dirs.map(d => (jsx("option", { value: d, children: d }, d))), jsx("option", { value: "__new__", children: "+ Create new directory\u2026" })] })) : (jsxs("div", { className: "flex", children: [jsx("input", { type: "text", value: newDirName, placeholder: "new-folder-name", onChange: e => setNewDirName(e.target.value), onBlur: acceptNewDir, onKeyDown: e => e.key === 'Enter' && acceptNewDir(), className: "form-input flex-1" }), jsx(Button, { onClick: acceptNewDir, className: "ml-2", children: "OK" })] })) }), jsx(Button, { color: "blue", variant: "primary", onClick: handleUploadAll, disabled: busy || !queue.length, children: busy ? 'Uploading…' : `Upload ${queue.length}` })] }), message && (jsx("p", { className: `text-center text-sm ${message.includes('failed') ? 'text-red-600' : 'text-gray-600'}`, role: "alert", children: message })), queue.length > 0 && (jsxs("div", { className: "wm-container mt-4", children: [jsx("h3", { className: "mb-2 text-sm font-semibold text-gray-700", children: "Queued Files" }), jsx("ul", { className: "max-h-64 space-y-4 overflow-y-auto", children: queue.map(({ file, relPath, progress }) => (jsxs("li", { className: "space-y-1", children: [jsxs("div", { className: "flex justify-between items-center", children: [jsxs("span", { className: "truncate text-sm text-gray-800", children: [relPath, file.name] }), jsxs("span", { className: "text-xs text-gray-500", children: [progress, "%"] })] }), jsx("div", { className: "w-full bg-gray-200 rounded-full h-2 overflow-hidden", children: jsx("div", { className: "bg-indigo-600 h-2", style: { width: `${progress}%` } }) })] }, `${file.name}-${file.size}`))) })] }))] }));
962
1019
  }
963
1020
 
964
1021
  const SecureFiles = () => {