@vryzel/file-next-ui 0.4.4 → 0.5.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/README.md +6 -4
- package/dist/index.cjs +595 -152
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -4
- package/dist/index.d.ts +22 -4
- package/dist/index.js +597 -154
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
package/dist/index.cjs
CHANGED
|
@@ -6,6 +6,7 @@ var lucideReact = require('lucide-react');
|
|
|
6
6
|
var clsx = require('clsx');
|
|
7
7
|
var tailwindMerge = require('tailwind-merge');
|
|
8
8
|
var jsxRuntime = require('react/jsx-runtime');
|
|
9
|
+
var reactVirtual = require('@tanstack/react-virtual');
|
|
9
10
|
|
|
10
11
|
// src/file-explorer.tsx
|
|
11
12
|
function cn(...inputs) {
|
|
@@ -129,11 +130,17 @@ var defaultLabels = {
|
|
|
129
130
|
clearClipboard: "Clear clipboard",
|
|
130
131
|
uploadStatus: "uploading",
|
|
131
132
|
dismissUploads: "Dismiss uploads",
|
|
133
|
+
cancelQueue: "Cancel queue",
|
|
134
|
+
removeUpload: "Remove from queue",
|
|
132
135
|
uploadQueued: "Queued",
|
|
133
136
|
uploadDone: "Done",
|
|
134
137
|
uploadFailed: "Failed",
|
|
135
138
|
unknownType: "Unknown type",
|
|
136
|
-
previewUnavailable: "Preview is not available for this file."
|
|
139
|
+
previewUnavailable: "Preview is not available for this file.",
|
|
140
|
+
closePreview: "Close preview",
|
|
141
|
+
zoomIn: "Zoom in",
|
|
142
|
+
zoomOut: "Zoom out",
|
|
143
|
+
zoomFit: "Fit"
|
|
137
144
|
};
|
|
138
145
|
var LabelsContext = react.createContext(defaultLabels);
|
|
139
146
|
function ExplorerLabelsProvider({
|
|
@@ -282,6 +289,39 @@ function useExplorerItemPointer(opts) {
|
|
|
282
289
|
}, [coarse, file, onActivate]);
|
|
283
290
|
return { onPointerDown, onClick, onDoubleClick };
|
|
284
291
|
}
|
|
292
|
+
function shouldLoadMore(input) {
|
|
293
|
+
const threshold = input.threshold ?? 8;
|
|
294
|
+
if (!input.hasMore || input.loading || input.loaded === 0) return false;
|
|
295
|
+
if (input.lastIndex === void 0) return false;
|
|
296
|
+
return input.lastIndex >= input.loaded - 1 - threshold;
|
|
297
|
+
}
|
|
298
|
+
function gridColumnCount(width) {
|
|
299
|
+
if (width < 640) return 3;
|
|
300
|
+
if (width < 768) return 5;
|
|
301
|
+
if (width < 1024) return 6;
|
|
302
|
+
return 8;
|
|
303
|
+
}
|
|
304
|
+
function selectionEdgeIndex(files, selectedIds, previous) {
|
|
305
|
+
let minPrev = Number.POSITIVE_INFINITY;
|
|
306
|
+
const added = [];
|
|
307
|
+
for (let i = 0; i < files.length; i++) {
|
|
308
|
+
const id = files[i]?.id;
|
|
309
|
+
if (!id) continue;
|
|
310
|
+
if (previous.has(id) && i < minPrev) minPrev = i;
|
|
311
|
+
if (selectedIds.has(id) && !previous.has(id)) added.push(i);
|
|
312
|
+
}
|
|
313
|
+
const minAdded = added[0];
|
|
314
|
+
const maxAdded = added[added.length - 1];
|
|
315
|
+
if (minAdded === void 0 || maxAdded === void 0) return -1;
|
|
316
|
+
return minAdded < minPrev ? minAdded : maxAdded;
|
|
317
|
+
}
|
|
318
|
+
function useLoadMoreOnEnd(input) {
|
|
319
|
+
const { lastIndex, loaded, hasMore, loading, onLoadMore } = input;
|
|
320
|
+
react.useEffect(() => {
|
|
321
|
+
if (!onLoadMore) return;
|
|
322
|
+
if (shouldLoadMore({ lastIndex, loaded, hasMore, loading })) onLoadMore();
|
|
323
|
+
}, [hasMore, lastIndex, loaded, loading, onLoadMore]);
|
|
324
|
+
}
|
|
285
325
|
var ROW_GRID = "grid-cols-[2.25rem_1.5rem_minmax(12rem,1fr)_5rem_2.75rem] sm:grid-cols-[2.25rem_1.25rem_minmax(0,1fr)_5rem_7.5rem]";
|
|
286
326
|
var DRAG_MIME = "application/x-file-next-id";
|
|
287
327
|
function ExplorerListView(props) {
|
|
@@ -305,58 +345,123 @@ function ExplorerListView(props) {
|
|
|
305
345
|
renamingId,
|
|
306
346
|
onRenameCommit,
|
|
307
347
|
onRenameCancel,
|
|
308
|
-
className
|
|
348
|
+
className,
|
|
349
|
+
scrollRef: scrollRefProp,
|
|
350
|
+
hasMore = false,
|
|
351
|
+
loadingMore = false,
|
|
352
|
+
onLoadMore
|
|
309
353
|
} = props;
|
|
310
354
|
const labels = useExplorerLabels();
|
|
311
355
|
const coarse = useCoarsePointer();
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
356
|
+
const ownScrollRef = react.useRef(null);
|
|
357
|
+
const scrollRef = scrollRefProp ?? ownScrollRef;
|
|
358
|
+
const headerRef = react.useRef(null);
|
|
359
|
+
const [scrollMargin, setScrollMargin] = react.useState(0);
|
|
360
|
+
react.useLayoutEffect(() => {
|
|
361
|
+
const header = headerRef.current;
|
|
362
|
+
if (!header) return;
|
|
363
|
+
const measure = () => setScrollMargin(header.offsetHeight);
|
|
364
|
+
measure();
|
|
365
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
366
|
+
const observer = new ResizeObserver(measure);
|
|
367
|
+
observer.observe(header);
|
|
368
|
+
return () => observer.disconnect();
|
|
369
|
+
}, []);
|
|
370
|
+
const virtualizer = reactVirtual.useVirtualizer({
|
|
371
|
+
count: files.length,
|
|
372
|
+
getScrollElement: () => scrollRef.current,
|
|
373
|
+
estimateSize: () => coarse ? 64 : 44,
|
|
374
|
+
overscan: 8,
|
|
375
|
+
scrollMargin,
|
|
376
|
+
scrollPaddingStart: scrollMargin,
|
|
377
|
+
getItemKey: (index) => files[index]?.id ?? index,
|
|
378
|
+
initialRect: { width: 800, height: 600 }
|
|
379
|
+
});
|
|
380
|
+
const virtualItems = virtualizer.getVirtualItems();
|
|
381
|
+
useLoadMoreOnEnd({
|
|
382
|
+
lastIndex: virtualItems.at(-1)?.index,
|
|
383
|
+
loaded: files.length,
|
|
384
|
+
hasMore,
|
|
385
|
+
loading: loadingMore,
|
|
386
|
+
onLoadMore
|
|
387
|
+
});
|
|
388
|
+
const prevSelected = react.useRef(selectedIds);
|
|
389
|
+
react.useEffect(() => {
|
|
390
|
+
const index = renamingId ? files.findIndex((file) => file.id === renamingId) : selectionEdgeIndex(files, selectedIds, prevSelected.current);
|
|
391
|
+
prevSelected.current = selectedIds;
|
|
392
|
+
if (index < 0) return;
|
|
393
|
+
virtualizer.scrollToIndex(index, { align: "auto" });
|
|
394
|
+
}, [renamingId, selectedIds]);
|
|
395
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
396
|
+
"div",
|
|
397
|
+
{
|
|
398
|
+
ref: scrollRefProp ? void 0 : ownScrollRef,
|
|
399
|
+
className: cn(
|
|
400
|
+
!scrollRefProp && "min-h-0 flex-1 overflow-y-auto overflow-x-auto overscroll-x-contain",
|
|
401
|
+
className
|
|
402
|
+
),
|
|
403
|
+
children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "min-w-[32rem] sm:min-w-0", children: [
|
|
404
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
405
|
+
"div",
|
|
406
|
+
{
|
|
407
|
+
ref: headerRef,
|
|
408
|
+
role: "row",
|
|
409
|
+
className: cn(
|
|
410
|
+
"sticky top-0 z-10 grid items-center gap-3 border-b border-border bg-background px-3 py-2 font-mono text-[10px] font-medium uppercase tracking-[0.12em] text-muted-foreground",
|
|
411
|
+
ROW_GRID
|
|
412
|
+
),
|
|
413
|
+
children: [
|
|
414
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true" }),
|
|
415
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true" }),
|
|
416
|
+
/* @__PURE__ */ jsxRuntime.jsx(SortHeader, { col: "name", label: labels.sortName, sortKey, sortDirection, onSort: onSortChange }),
|
|
417
|
+
/* @__PURE__ */ jsxRuntime.jsx(SortHeader, { col: "size", label: labels.sortSize, sortKey, sortDirection, onSort: onSortChange, className: "justify-end text-right" }),
|
|
418
|
+
coarse ? /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true" }) : /* @__PURE__ */ jsxRuntime.jsx(SortHeader, { col: "modified", label: labels.sortModified, sortKey, sortDirection, onSort: onSortChange, className: "hidden sm:flex" })
|
|
419
|
+
]
|
|
420
|
+
}
|
|
320
421
|
),
|
|
321
|
-
children:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
/* @__PURE__ */ jsxRuntime.jsx(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
422
|
+
/* @__PURE__ */ jsxRuntime.jsx("ul", { role: "list", className: "relative z-0 w-full", style: { height: virtualizer.getTotalSize() }, children: virtualItems.map((virtualRow) => {
|
|
423
|
+
const file = files[virtualRow.index];
|
|
424
|
+
if (!file) return null;
|
|
425
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
426
|
+
ExplorerListRow,
|
|
427
|
+
{
|
|
428
|
+
file,
|
|
429
|
+
idx: virtualRow.index,
|
|
430
|
+
dataIndex: virtualRow.index,
|
|
431
|
+
measureRef: virtualizer.measureElement,
|
|
432
|
+
translateY: virtualRow.start - virtualizer.options.scrollMargin,
|
|
433
|
+
selected: selectedIds.has(file.id),
|
|
434
|
+
dragging: draggingIds?.has(file.id) ?? file.id === draggingId,
|
|
435
|
+
dropTarget: file.id === dropTargetId && file.kind === "folder",
|
|
436
|
+
isProtected: protectedIds?.has(file.id) ?? false,
|
|
437
|
+
draggingId,
|
|
438
|
+
dropTargetId,
|
|
439
|
+
coarse,
|
|
440
|
+
onSelect,
|
|
441
|
+
onActivate,
|
|
442
|
+
onContextMenu,
|
|
443
|
+
onDragStart,
|
|
444
|
+
onDragEnd,
|
|
445
|
+
onDrop,
|
|
446
|
+
onDragOverRow,
|
|
447
|
+
renaming: file.id === renamingId,
|
|
448
|
+
onRenameCommit,
|
|
449
|
+
onRenameCancel
|
|
450
|
+
},
|
|
451
|
+
virtualRow.key
|
|
452
|
+
);
|
|
453
|
+
}) }),
|
|
454
|
+
loadingMore ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "px-4 py-3 text-center font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: labels.loading }) : null
|
|
455
|
+
] })
|
|
456
|
+
}
|
|
457
|
+
);
|
|
356
458
|
}
|
|
357
459
|
function ExplorerListRow({
|
|
358
460
|
file,
|
|
359
461
|
idx,
|
|
462
|
+
dataIndex,
|
|
463
|
+
measureRef,
|
|
464
|
+
translateY,
|
|
360
465
|
selected,
|
|
361
466
|
dragging,
|
|
362
467
|
dropTarget,
|
|
@@ -383,6 +488,8 @@ function ExplorerListRow({
|
|
|
383
488
|
{
|
|
384
489
|
role: "row",
|
|
385
490
|
"data-file-id": file.id,
|
|
491
|
+
"data-index": dataIndex,
|
|
492
|
+
ref: measureRef,
|
|
386
493
|
"aria-selected": selected,
|
|
387
494
|
draggable: !coarse && !renaming,
|
|
388
495
|
onDragStart: (e) => {
|
|
@@ -424,8 +531,9 @@ function ExplorerListRow({
|
|
|
424
531
|
else onDragEnd();
|
|
425
532
|
},
|
|
426
533
|
tabIndex: 0,
|
|
534
|
+
style: { transform: `translateY(${translateY}px)` },
|
|
427
535
|
className: cn(
|
|
428
|
-
"
|
|
536
|
+
"absolute left-0 top-0 grid w-full items-center gap-3 border-b border-border/60 px-3 py-3.5 text-sm outline-none sm:py-2",
|
|
429
537
|
"touch-manipulation select-none [-webkit-touch-callout:none]",
|
|
430
538
|
ROW_GRID,
|
|
431
539
|
"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
@@ -508,6 +616,7 @@ var formatSize = (bytes) => {
|
|
|
508
616
|
};
|
|
509
617
|
var formatDate = (d) => d.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" });
|
|
510
618
|
var EXPLORER_DRAG_MIME = DRAG_MIME;
|
|
619
|
+
var GRID_PAD = 12;
|
|
511
620
|
function ExplorerGridView(props) {
|
|
512
621
|
const {
|
|
513
622
|
files,
|
|
@@ -526,38 +635,116 @@ function ExplorerGridView(props) {
|
|
|
526
635
|
renamingId,
|
|
527
636
|
onRenameCommit,
|
|
528
637
|
onRenameCancel,
|
|
529
|
-
className
|
|
638
|
+
className,
|
|
639
|
+
scrollRef: scrollRefProp,
|
|
640
|
+
hasMore = false,
|
|
641
|
+
loadingMore = false,
|
|
642
|
+
onLoadMore
|
|
530
643
|
} = props;
|
|
531
644
|
const labels = useExplorerLabels();
|
|
532
645
|
const coarse = useCoarsePointer();
|
|
533
|
-
|
|
534
|
-
|
|
646
|
+
const ownScrollRef = react.useRef(null);
|
|
647
|
+
const scrollRef = scrollRefProp ?? ownScrollRef;
|
|
648
|
+
const [width, setWidth] = react.useState(null);
|
|
649
|
+
react.useLayoutEffect(() => {
|
|
650
|
+
const el = scrollRef.current;
|
|
651
|
+
if (!el) return;
|
|
652
|
+
const measure = () => setWidth(el.clientWidth);
|
|
653
|
+
measure();
|
|
654
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
655
|
+
const observer = new ResizeObserver(measure);
|
|
656
|
+
observer.observe(el);
|
|
657
|
+
return () => observer.disconnect();
|
|
658
|
+
}, [scrollRef]);
|
|
659
|
+
const columns = width != null && width > 0 ? gridColumnCount(width) : 6;
|
|
660
|
+
const rowCount = Math.ceil(files.length / columns);
|
|
661
|
+
const virtualizer = reactVirtual.useVirtualizer({
|
|
662
|
+
count: rowCount,
|
|
663
|
+
getScrollElement: () => scrollRef.current,
|
|
664
|
+
estimateSize: () => 156,
|
|
665
|
+
overscan: 4,
|
|
666
|
+
gap: 6,
|
|
667
|
+
// ponytail: gap-1.5 at 16px root
|
|
668
|
+
scrollMargin: GRID_PAD,
|
|
669
|
+
getItemKey: (index) => files[index * columns]?.id ?? index,
|
|
670
|
+
initialRect: { width: 800, height: 600 }
|
|
671
|
+
});
|
|
672
|
+
const virtualRows = virtualizer.getVirtualItems();
|
|
673
|
+
const viewportEnd = (virtualizer.scrollOffset ?? 0) + (virtualizer.scrollRect?.height ?? 0);
|
|
674
|
+
const lastVisibleRow = virtualRows.filter((row) => row.start < viewportEnd).at(-1);
|
|
675
|
+
useLoadMoreOnEnd({
|
|
676
|
+
lastIndex: lastVisibleRow ? Math.min(files.length - 1, (lastVisibleRow.index + 1) * columns - 1) : void 0,
|
|
677
|
+
loaded: files.length,
|
|
678
|
+
hasMore,
|
|
679
|
+
loading: loadingMore,
|
|
680
|
+
onLoadMore
|
|
681
|
+
});
|
|
682
|
+
const prevSelected = react.useRef(selectedIds);
|
|
683
|
+
react.useEffect(() => {
|
|
684
|
+
const itemIndex = renamingId ? files.findIndex((file) => file.id === renamingId) : selectionEdgeIndex(files, selectedIds, prevSelected.current);
|
|
685
|
+
prevSelected.current = selectedIds;
|
|
686
|
+
if (itemIndex < 0) return;
|
|
687
|
+
virtualizer.scrollToIndex(Math.floor(itemIndex / columns), { align: "auto" });
|
|
688
|
+
}, [renamingId, selectedIds]);
|
|
689
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
690
|
+
"div",
|
|
535
691
|
{
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
692
|
+
ref: scrollRefProp ? void 0 : ownScrollRef,
|
|
693
|
+
className: cn(!scrollRefProp && "min-h-0 flex-1 overflow-y-auto", className),
|
|
694
|
+
children: [
|
|
695
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { role: "list", className: "pt-3", children: [
|
|
696
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { role: "presentation", className: "relative z-0 w-full", style: { height: virtualizer.getTotalSize() }, children: virtualRows.map((virtualRow) => {
|
|
697
|
+
const start = virtualRow.index * columns;
|
|
698
|
+
const rowFiles = files.slice(start, start + columns);
|
|
699
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
700
|
+
"div",
|
|
701
|
+
{
|
|
702
|
+
role: "presentation",
|
|
703
|
+
"data-index": virtualRow.index,
|
|
704
|
+
ref: virtualizer.measureElement,
|
|
705
|
+
className: "absolute left-0 top-0 grid w-full gap-1.5 px-3",
|
|
706
|
+
style: {
|
|
707
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
708
|
+
transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`
|
|
709
|
+
},
|
|
710
|
+
children: rowFiles.map((file, offset) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
711
|
+
ExplorerGridItem,
|
|
712
|
+
{
|
|
713
|
+
file,
|
|
714
|
+
idx: start + offset,
|
|
715
|
+
selected: selectedIds.has(file.id),
|
|
716
|
+
dragging: draggingIds?.has(file.id) ?? file.id === draggingId,
|
|
717
|
+
dropTarget: file.id === dropTargetId && file.kind === "folder",
|
|
718
|
+
isProtected: protectedIds?.has(file.id) ?? false,
|
|
719
|
+
draggingId,
|
|
720
|
+
dropTargetId,
|
|
721
|
+
coarse,
|
|
722
|
+
folderLabel: labels.folder,
|
|
723
|
+
inUseLabel: labels.inUse,
|
|
724
|
+
actionsLabel: labels.itemActions,
|
|
725
|
+
onSelect,
|
|
726
|
+
onActivate,
|
|
727
|
+
onContextMenu,
|
|
728
|
+
onDragStart,
|
|
729
|
+
onDragEnd,
|
|
730
|
+
onDrop,
|
|
731
|
+
onDragOverRow,
|
|
732
|
+
renaming: file.id === renamingId,
|
|
733
|
+
onRenameCommit,
|
|
734
|
+
onRenameCancel
|
|
735
|
+
},
|
|
736
|
+
file.id
|
|
737
|
+
))
|
|
738
|
+
},
|
|
739
|
+
virtualRow.key
|
|
740
|
+
);
|
|
741
|
+
}) }),
|
|
742
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-3" })
|
|
743
|
+
] }),
|
|
744
|
+
loadingMore ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "px-4 py-3 text-center font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: labels.loading }) : null
|
|
745
|
+
]
|
|
746
|
+
}
|
|
747
|
+
);
|
|
561
748
|
}
|
|
562
749
|
function ExplorerGridItem({
|
|
563
750
|
file,
|
|
@@ -614,7 +801,7 @@ function ExplorerGridItem({
|
|
|
614
801
|
) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn("truncate text-[11px]", file.kind === "folder" && "text-primary"), children: file.name }),
|
|
615
802
|
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono text-[9px] uppercase tracking-[0.12em] text-muted-foreground", children: file.kind === "folder" ? folderLabel : formatSizeShort(file.size) })
|
|
616
803
|
] });
|
|
617
|
-
return /* @__PURE__ */ jsxRuntime.jsxs("
|
|
804
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { role: "listitem", className: "relative", children: [
|
|
618
805
|
coarse && onContextMenu ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
619
806
|
"button",
|
|
620
807
|
{
|
|
@@ -1316,19 +1503,47 @@ function ExplorerClipboardToast({
|
|
|
1316
1503
|
)
|
|
1317
1504
|
] });
|
|
1318
1505
|
}
|
|
1506
|
+
function uploadQueuePercent(items) {
|
|
1507
|
+
const total = items.reduce((sum, item) => sum + item.size, 0);
|
|
1508
|
+
if (total <= 0) return items.length === 0 ? 0 : 100;
|
|
1509
|
+
const loaded = items.reduce((sum, item) => {
|
|
1510
|
+
if (item.status === "success") return sum + item.size;
|
|
1511
|
+
if (item.status === "uploading" || item.status === "error") {
|
|
1512
|
+
return sum + item.size * item.progress / 100;
|
|
1513
|
+
}
|
|
1514
|
+
return sum;
|
|
1515
|
+
}, 0);
|
|
1516
|
+
return Math.min(100, Math.round(loaded / total * 100));
|
|
1517
|
+
}
|
|
1319
1518
|
function ExplorerUploadStatus({
|
|
1320
1519
|
items,
|
|
1321
1520
|
onDismiss,
|
|
1521
|
+
onCancel,
|
|
1522
|
+
onRemove,
|
|
1322
1523
|
className
|
|
1323
1524
|
}) {
|
|
1324
1525
|
const labels = useExplorerLabels();
|
|
1526
|
+
const listRef = react.useRef(null);
|
|
1527
|
+
const activeId = items.find((item) => item.status === "uploading")?.id;
|
|
1528
|
+
react.useLayoutEffect(() => {
|
|
1529
|
+
const list = listRef.current;
|
|
1530
|
+
if (!list || !activeId) return;
|
|
1531
|
+
const row = list.querySelector(`[data-upload-id="${activeId}"]`);
|
|
1532
|
+
if (!row) return;
|
|
1533
|
+
const top = row.offsetTop;
|
|
1534
|
+
const bottom = top + row.offsetHeight;
|
|
1535
|
+
if (top < list.scrollTop) list.scrollTop = top;
|
|
1536
|
+
else if (bottom > list.scrollTop + list.clientHeight) {
|
|
1537
|
+
list.scrollTop = bottom - list.clientHeight;
|
|
1538
|
+
}
|
|
1539
|
+
}, [activeId]);
|
|
1325
1540
|
if (items.length === 0) return null;
|
|
1326
1541
|
const done = items.filter((item) => item.status === "success").length;
|
|
1327
1542
|
const failed = items.filter((item) => item.status === "error").length;
|
|
1328
1543
|
const active = items.some(
|
|
1329
1544
|
(item) => item.status === "queued" || item.status === "uploading"
|
|
1330
1545
|
);
|
|
1331
|
-
const
|
|
1546
|
+
const totalPercent = uploadQueuePercent(items);
|
|
1332
1547
|
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
1333
1548
|
"div",
|
|
1334
1549
|
{
|
|
@@ -1352,20 +1567,44 @@ function ExplorerUploadStatus({
|
|
|
1352
1567
|
failed
|
|
1353
1568
|
] }) : null
|
|
1354
1569
|
] }),
|
|
1570
|
+
/* @__PURE__ */ jsxRuntime.jsxs("span", { className: "ml-auto font-mono text-[10px] tabular-nums text-primary", children: [
|
|
1571
|
+
totalPercent,
|
|
1572
|
+
"%"
|
|
1573
|
+
] }),
|
|
1574
|
+
active && onCancel ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
1575
|
+
"button",
|
|
1576
|
+
{
|
|
1577
|
+
type: "button",
|
|
1578
|
+
className: "inline-flex h-7 items-center rounded-[10px] border border-border px-2 text-[10px] font-medium hover:bg-muted",
|
|
1579
|
+
onClick: onCancel,
|
|
1580
|
+
children: labels.cancelQueue
|
|
1581
|
+
}
|
|
1582
|
+
) : null,
|
|
1355
1583
|
!active ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
1356
1584
|
"button",
|
|
1357
1585
|
{
|
|
1358
1586
|
type: "button",
|
|
1359
1587
|
"aria-label": labels.dismissUploads,
|
|
1360
|
-
className: "
|
|
1588
|
+
className: "inline-flex size-7 items-center justify-center rounded-[10px] text-muted-foreground hover:bg-muted",
|
|
1361
1589
|
onClick: onDismiss,
|
|
1362
1590
|
children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { "aria-hidden": "true", className: "size-4" })
|
|
1363
1591
|
}
|
|
1364
|
-
) :
|
|
1592
|
+
) : null
|
|
1365
1593
|
] }),
|
|
1366
|
-
/* @__PURE__ */ jsxRuntime.jsx("
|
|
1594
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "mb-2 h-1 overflow-hidden rounded-[10px] bg-muted", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-full bg-primary", style: { width: `${totalPercent}%` } }) }),
|
|
1595
|
+
/* @__PURE__ */ jsxRuntime.jsx("ul", { ref: listRef, className: "flex max-h-48 min-h-0 flex-col gap-1.5 overflow-y-auto overscroll-contain", children: items.map((item) => /* @__PURE__ */ jsxRuntime.jsxs("li", { "data-upload-id": item.id, className: "min-w-0 shrink-0", children: [
|
|
1367
1596
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1368
1597
|
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "min-w-0 flex-1 truncate text-xs", children: item.name }),
|
|
1598
|
+
onRemove ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
1599
|
+
"button",
|
|
1600
|
+
{
|
|
1601
|
+
type: "button",
|
|
1602
|
+
"aria-label": `${labels.removeUpload} ${item.name}`,
|
|
1603
|
+
className: "inline-flex size-6 shrink-0 items-center justify-center rounded-[10px] text-muted-foreground hover:bg-muted",
|
|
1604
|
+
onClick: () => onRemove(item.id),
|
|
1605
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { "aria-hidden": "true", className: "size-3.5" })
|
|
1606
|
+
}
|
|
1607
|
+
) : null,
|
|
1369
1608
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1370
1609
|
"span",
|
|
1371
1610
|
{
|
|
@@ -1392,6 +1631,11 @@ function useUploadQueue(options) {
|
|
|
1392
1631
|
confirmUploadRef.current = options.confirmUpload;
|
|
1393
1632
|
const queueRef = react.useRef([]);
|
|
1394
1633
|
const running = react.useRef(false);
|
|
1634
|
+
const dropped = react.useRef(/* @__PURE__ */ new Set());
|
|
1635
|
+
const activeId = react.useRef(null);
|
|
1636
|
+
const stepAbort = react.useRef(null);
|
|
1637
|
+
const xhrRef = react.useRef(null);
|
|
1638
|
+
const xhrOwner = react.useRef(null);
|
|
1395
1639
|
const [items, setItems] = react.useState([]);
|
|
1396
1640
|
const publish = react.useCallback(() => {
|
|
1397
1641
|
setItems(queueRef.current.map(({ content: _content, ...item }) => item));
|
|
@@ -1401,6 +1645,13 @@ function useUploadQueue(options) {
|
|
|
1401
1645
|
const next = queueRef.current.find((item) => item.status === "queued");
|
|
1402
1646
|
if (!next) return;
|
|
1403
1647
|
running.current = true;
|
|
1648
|
+
activeId.current = next.id;
|
|
1649
|
+
const controller = new AbortController();
|
|
1650
|
+
stepAbort.current = controller;
|
|
1651
|
+
let confirming = false;
|
|
1652
|
+
const forget = () => {
|
|
1653
|
+
queueRef.current = queueRef.current.filter((item) => item.id !== next.id);
|
|
1654
|
+
};
|
|
1404
1655
|
next.status = "uploading";
|
|
1405
1656
|
next.progress = 0;
|
|
1406
1657
|
publish();
|
|
@@ -1411,31 +1662,62 @@ function useUploadQueue(options) {
|
|
|
1411
1662
|
type: next.type,
|
|
1412
1663
|
content: next.content,
|
|
1413
1664
|
parentId: next.parentId
|
|
1414
|
-
});
|
|
1665
|
+
}, controller.signal);
|
|
1666
|
+
if (dropped.current.has(next.id)) {
|
|
1667
|
+
forget();
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1415
1670
|
let lastPublish = 0;
|
|
1416
|
-
await putFile(
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1671
|
+
await putFile(
|
|
1672
|
+
target,
|
|
1673
|
+
next,
|
|
1674
|
+
(progress) => {
|
|
1675
|
+
next.progress = progress;
|
|
1676
|
+
const now = Date.now();
|
|
1677
|
+
if (progress === 100 || now - lastPublish > 200) {
|
|
1678
|
+
lastPublish = now;
|
|
1679
|
+
publish();
|
|
1680
|
+
}
|
|
1681
|
+
},
|
|
1682
|
+
(xhr) => {
|
|
1683
|
+
xhrRef.current = xhr;
|
|
1684
|
+
xhrOwner.current = next.id;
|
|
1422
1685
|
}
|
|
1423
|
-
|
|
1686
|
+
);
|
|
1687
|
+
if (dropped.current.has(next.id)) {
|
|
1688
|
+
forget();
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
confirming = true;
|
|
1424
1692
|
await confirmUploadRef.current?.({
|
|
1425
1693
|
name: next.name,
|
|
1426
1694
|
size: next.size,
|
|
1427
1695
|
type: next.type,
|
|
1428
1696
|
content: next.content
|
|
1429
|
-
});
|
|
1697
|
+
}, controller.signal);
|
|
1698
|
+
dropped.current.delete(next.id);
|
|
1430
1699
|
next.status = "success";
|
|
1431
1700
|
next.progress = 100;
|
|
1432
1701
|
if (typeof window !== "undefined") {
|
|
1433
1702
|
window.dispatchEvent(new Event("file-next-uploaded"));
|
|
1434
1703
|
}
|
|
1435
1704
|
} catch (error) {
|
|
1705
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
1706
|
+
if (!confirming && dropped.current.has(next.id) || confirming && aborted) {
|
|
1707
|
+
forget();
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1436
1710
|
next.status = "error";
|
|
1437
1711
|
next.error = error instanceof Error ? error.message : "Upload failed";
|
|
1438
1712
|
} finally {
|
|
1713
|
+
if (activeId.current === next.id) {
|
|
1714
|
+
activeId.current = null;
|
|
1715
|
+
stepAbort.current = null;
|
|
1716
|
+
}
|
|
1717
|
+
if (xhrOwner.current === next.id) {
|
|
1718
|
+
xhrRef.current = null;
|
|
1719
|
+
xhrOwner.current = null;
|
|
1720
|
+
}
|
|
1439
1721
|
publish();
|
|
1440
1722
|
running.current = false;
|
|
1441
1723
|
void pump();
|
|
@@ -1460,6 +1742,26 @@ function useUploadQueue(options) {
|
|
|
1460
1742
|
},
|
|
1461
1743
|
[publish, pump]
|
|
1462
1744
|
);
|
|
1745
|
+
const dropIds = react.useCallback(
|
|
1746
|
+
(ids) => {
|
|
1747
|
+
if (ids.length === 0) return;
|
|
1748
|
+
for (const id of ids) dropped.current.add(id);
|
|
1749
|
+
if (xhrOwner.current && ids.includes(xhrOwner.current)) xhrRef.current?.abort();
|
|
1750
|
+
if (activeId.current && ids.includes(activeId.current)) stepAbort.current?.abort();
|
|
1751
|
+
const idSet = new Set(ids);
|
|
1752
|
+
queueRef.current = queueRef.current.filter(
|
|
1753
|
+
(item) => !idSet.has(item.id) || item.id === activeId.current
|
|
1754
|
+
);
|
|
1755
|
+
publish();
|
|
1756
|
+
},
|
|
1757
|
+
[publish]
|
|
1758
|
+
);
|
|
1759
|
+
const remove = react.useCallback((id) => dropIds([id]), [dropIds]);
|
|
1760
|
+
const cancel = react.useCallback(() => {
|
|
1761
|
+
dropIds(
|
|
1762
|
+
queueRef.current.filter((item) => item.status === "queued" || item.status === "uploading").map((item) => item.id)
|
|
1763
|
+
);
|
|
1764
|
+
}, [dropIds]);
|
|
1463
1765
|
const dismiss = react.useCallback(() => {
|
|
1464
1766
|
queueRef.current = queueRef.current.filter(
|
|
1465
1767
|
(item) => item.status === "queued" || item.status === "uploading"
|
|
@@ -1479,11 +1781,19 @@ function useUploadQueue(options) {
|
|
|
1479
1781
|
window.clearTimeout(timeout);
|
|
1480
1782
|
};
|
|
1481
1783
|
}, [active, dismiss, itemCount]);
|
|
1482
|
-
return { items, enqueue, dismiss, active };
|
|
1784
|
+
return { items, enqueue, dismiss, cancel, remove, active };
|
|
1483
1785
|
}
|
|
1484
|
-
function putFile(target, file, onProgress) {
|
|
1786
|
+
function putFile(target, file, onProgress, onStart) {
|
|
1485
1787
|
return new Promise((resolve, reject) => {
|
|
1788
|
+
let settled = false;
|
|
1789
|
+
const finish = (fn) => {
|
|
1790
|
+
if (settled) return;
|
|
1791
|
+
settled = true;
|
|
1792
|
+
fn();
|
|
1793
|
+
};
|
|
1486
1794
|
const xhr = new XMLHttpRequest();
|
|
1795
|
+
onStart(xhr);
|
|
1796
|
+
xhr.addEventListener("abort", () => finish(() => reject(new Error("aborted"))));
|
|
1487
1797
|
xhr.upload.addEventListener("progress", (event) => {
|
|
1488
1798
|
if (event.lengthComputable) {
|
|
1489
1799
|
onProgress(Math.min(100, Math.round(event.loaded / event.total * 100)));
|
|
@@ -1497,13 +1807,13 @@ function putFile(target, file, onProgress) {
|
|
|
1497
1807
|
if (parsed.error?.message) message = parsed.error.message;
|
|
1498
1808
|
} catch {
|
|
1499
1809
|
}
|
|
1500
|
-
reject(new Error(message));
|
|
1810
|
+
finish(() => reject(new Error(message)));
|
|
1501
1811
|
return;
|
|
1502
1812
|
}
|
|
1503
|
-
resolve
|
|
1813
|
+
finish(resolve);
|
|
1504
1814
|
});
|
|
1505
1815
|
xhr.addEventListener("error", () => {
|
|
1506
|
-
reject(new Error("Upload failed"));
|
|
1816
|
+
finish(() => reject(new Error("Upload failed")));
|
|
1507
1817
|
});
|
|
1508
1818
|
xhr.open(target.method ?? "PUT", target.url);
|
|
1509
1819
|
xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
|
|
@@ -1524,8 +1834,16 @@ function UploadQueueProvider({
|
|
|
1524
1834
|
}) {
|
|
1525
1835
|
const uploads = useUploadQueue({ requestUpload, confirmUpload });
|
|
1526
1836
|
const panel = react.useMemo(
|
|
1527
|
-
() => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fixed right-4 bottom-4 z-50 flex w-[min(22rem,calc(100vw-2rem))] flex-col gap-2", children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1528
|
-
|
|
1837
|
+
() => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fixed right-4 bottom-4 z-50 flex w-[min(22rem,calc(100vw-2rem))] flex-col gap-2", children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1838
|
+
ExplorerUploadStatus,
|
|
1839
|
+
{
|
|
1840
|
+
items: uploads.items,
|
|
1841
|
+
onDismiss: uploads.dismiss,
|
|
1842
|
+
onCancel: uploads.cancel,
|
|
1843
|
+
onRemove: uploads.remove
|
|
1844
|
+
}
|
|
1845
|
+
) }),
|
|
1846
|
+
[uploads.cancel, uploads.dismiss, uploads.items, uploads.remove]
|
|
1529
1847
|
);
|
|
1530
1848
|
return /* @__PURE__ */ jsxRuntime.jsxs(EnqueueContext.Provider, { value: uploads.enqueue, children: [
|
|
1531
1849
|
children,
|
|
@@ -1661,6 +1979,7 @@ function FileExplorerInner(props) {
|
|
|
1661
1979
|
onMove
|
|
1662
1980
|
});
|
|
1663
1981
|
const uploadInputRef = react.useRef(null);
|
|
1982
|
+
const scrollRef = react.useRef(null);
|
|
1664
1983
|
const [query, setQuery] = react.useState("");
|
|
1665
1984
|
const [trashOpen, setTrashOpen] = react.useState(false);
|
|
1666
1985
|
const [pageItems, setPageItems] = react.useState([]);
|
|
@@ -1819,6 +2138,10 @@ function FileExplorerInner(props) {
|
|
|
1819
2138
|
},
|
|
1820
2139
|
[listTrash, query, searchFiles, trashOpen]
|
|
1821
2140
|
);
|
|
2141
|
+
const loadNext = react.useCallback(() => {
|
|
2142
|
+
if (overlayFiles) void loadOverlay("more");
|
|
2143
|
+
else void loadBrowse("more");
|
|
2144
|
+
}, [loadBrowse, loadOverlay, overlayFiles]);
|
|
1822
2145
|
const refreshTrashFlag = react.useCallback(async () => {
|
|
1823
2146
|
if (!listTrash) {
|
|
1824
2147
|
setTrashHasItems(false);
|
|
@@ -2122,7 +2445,7 @@ function FileExplorerInner(props) {
|
|
|
2122
2445
|
}
|
|
2123
2446
|
)
|
|
2124
2447
|
] }),
|
|
2125
|
-
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-y-auto", children: (overlayLoading || pageStatus === "loading") && displayFiles.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "px-4 py-12 text-center font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: labels.loading }) : overlayError || pageStatus === "error" && pageError ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "border-b border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive", children: (overlayError ?? pageError)?.message ?? labels.folderCreateFailed }) : displayFiles.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-full flex-col justify-center px-4 py-16 text-center", children: [
|
|
2448
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { ref: scrollRef, className: "min-h-0 flex-1 overflow-y-auto overflow-x-auto overscroll-x-contain", children: (overlayLoading || pageStatus === "loading") && displayFiles.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "px-4 py-12 text-center font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: labels.loading }) : overlayError || pageStatus === "error" && pageError ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "border-b border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive", children: (overlayError ?? pageError)?.message ?? labels.folderCreateFailed }) : displayFiles.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-h-full flex-col justify-center px-4 py-16 text-center", children: [
|
|
2126
2449
|
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: trashOpen ? labels.emptyTrash : query.trim() ? labels.noMatches : labels.emptyFolder }),
|
|
2127
2450
|
trashOpen ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: labels.trashRetention }) : !query.trim() ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: labels.dropHint }) : null
|
|
2128
2451
|
] }) : explorer.view === "list" ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
@@ -2153,7 +2476,11 @@ function FileExplorerInner(props) {
|
|
|
2153
2476
|
onSortChange: handleSortChange,
|
|
2154
2477
|
renamingId,
|
|
2155
2478
|
onRenameCommit: commitRename,
|
|
2156
|
-
onRenameCancel: cancelRename
|
|
2479
|
+
onRenameCancel: cancelRename,
|
|
2480
|
+
scrollRef,
|
|
2481
|
+
hasMore,
|
|
2482
|
+
loadingMore: loadingMore || overlayMore,
|
|
2483
|
+
onLoadMore: loadNext
|
|
2157
2484
|
}
|
|
2158
2485
|
)
|
|
2159
2486
|
}
|
|
@@ -2182,24 +2509,15 @@ function FileExplorerInner(props) {
|
|
|
2182
2509
|
onDragOverRow: explorer.setDropTarget,
|
|
2183
2510
|
renamingId,
|
|
2184
2511
|
onRenameCommit: commitRename,
|
|
2185
|
-
onRenameCancel: cancelRename
|
|
2512
|
+
onRenameCancel: cancelRename,
|
|
2513
|
+
scrollRef,
|
|
2514
|
+
hasMore,
|
|
2515
|
+
loadingMore: loadingMore || overlayMore,
|
|
2516
|
+
onLoadMore: loadNext
|
|
2186
2517
|
}
|
|
2187
2518
|
)
|
|
2188
2519
|
}
|
|
2189
2520
|
) }),
|
|
2190
|
-
hasMore && sortedFiles.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "shrink-0 border-t border-border p-3", children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
2191
|
-
"button",
|
|
2192
|
-
{
|
|
2193
|
-
type: "button",
|
|
2194
|
-
disabled: loadingMore || overlayMore,
|
|
2195
|
-
onClick: () => {
|
|
2196
|
-
if (overlayFiles) void loadOverlay("more");
|
|
2197
|
-
else void loadBrowse("more");
|
|
2198
|
-
},
|
|
2199
|
-
className: "inline-flex h-8 w-full items-center justify-center rounded-[10px] border border-border text-xs font-medium hover:bg-muted disabled:opacity-50",
|
|
2200
|
-
children: loadingMore || overlayMore ? labels.loading : labels.loadMore
|
|
2201
|
-
}
|
|
2202
|
-
) }) : null,
|
|
2203
2521
|
/* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "flex shrink-0 items-center justify-between gap-3 border-t border-border px-4 py-2", children: [
|
|
2204
2522
|
/* @__PURE__ */ jsxRuntime.jsxs("span", { className: "font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: [
|
|
2205
2523
|
/* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-primary tabular-nums", children: [
|
|
@@ -2380,18 +2698,27 @@ function ErrorState(props) {
|
|
|
2380
2698
|
}
|
|
2381
2699
|
);
|
|
2382
2700
|
}
|
|
2701
|
+
var MIN_ZOOM = 1;
|
|
2702
|
+
var MAX_ZOOM = 4;
|
|
2703
|
+
function clampPreviewZoom(scale) {
|
|
2704
|
+
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number.isFinite(scale) ? scale : 1));
|
|
2705
|
+
}
|
|
2383
2706
|
function canPreviewFile(file) {
|
|
2384
2707
|
return previewKind(file.mimeType, file.name) !== "none";
|
|
2385
2708
|
}
|
|
2386
2709
|
function previewKind(mimeType, name) {
|
|
2387
2710
|
const mime = mimeType.toLowerCase();
|
|
2388
2711
|
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
2389
|
-
if (mime
|
|
2712
|
+
if (mime === "text/html" || mime === "application/xhtml+xml" || ext === "html" || ext === "htm") {
|
|
2713
|
+
return "none";
|
|
2714
|
+
}
|
|
2715
|
+
if (mime.startsWith("image/") || ["png", "jpg", "jpeg", "gif", "webp", "avif"].includes(ext)) {
|
|
2390
2716
|
return "image";
|
|
2391
2717
|
}
|
|
2392
2718
|
if (mime === "application/pdf" || ext === "pdf") return "pdf";
|
|
2393
|
-
if (mime.startsWith("video/") || ["mp4", "webm"
|
|
2394
|
-
if (mime.startsWith("
|
|
2719
|
+
if (mime.startsWith("video/") || ["mp4", "webm"].includes(ext)) return "video";
|
|
2720
|
+
if (mime.startsWith("audio/") || ["mp3", "wav", "ogg"].includes(ext)) return "audio";
|
|
2721
|
+
if (mime.startsWith("text/") || ["txt", "csv", "md"].includes(ext)) return "text";
|
|
2395
2722
|
return "none";
|
|
2396
2723
|
}
|
|
2397
2724
|
function FilePreviewDialog({
|
|
@@ -2401,46 +2728,162 @@ function FilePreviewDialog({
|
|
|
2401
2728
|
onDownload
|
|
2402
2729
|
}) {
|
|
2403
2730
|
const labels = useExplorerLabels();
|
|
2731
|
+
const [scale, setScale] = react.useState(1);
|
|
2732
|
+
const [pan, setPan] = react.useState({ x: 0, y: 0 });
|
|
2733
|
+
const drag = react.useRef(null);
|
|
2734
|
+
const closeRef = react.useRef(null);
|
|
2735
|
+
react.useEffect(() => {
|
|
2736
|
+
setScale(1);
|
|
2737
|
+
setPan({ x: 0, y: 0 });
|
|
2738
|
+
closeRef.current?.focus();
|
|
2739
|
+
}, [file?.id, src]);
|
|
2740
|
+
react.useEffect(() => {
|
|
2741
|
+
const onKey = (event) => {
|
|
2742
|
+
if (event.key === "Escape") onClose();
|
|
2743
|
+
};
|
|
2744
|
+
window.addEventListener("keydown", onKey);
|
|
2745
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
2746
|
+
}, [onClose]);
|
|
2404
2747
|
if (!file) return null;
|
|
2405
2748
|
const kind = previewKind(file.mimeType, file.name);
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
"
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
className: "
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2749
|
+
const zoom = (next) => {
|
|
2750
|
+
const clamped = clampPreviewZoom(next);
|
|
2751
|
+
setScale(clamped);
|
|
2752
|
+
if (clamped === 1) setPan({ x: 0, y: 0 });
|
|
2753
|
+
};
|
|
2754
|
+
const stop = (event) => event.stopPropagation();
|
|
2755
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
2756
|
+
"div",
|
|
2757
|
+
{
|
|
2758
|
+
role: "dialog",
|
|
2759
|
+
"aria-modal": "true",
|
|
2760
|
+
"aria-label": file.name,
|
|
2761
|
+
className: "fixed inset-0 z-50 bg-black/90 text-white",
|
|
2762
|
+
onClick: onClose,
|
|
2763
|
+
children: [
|
|
2764
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-x-0 top-0 z-10 flex items-center gap-2 px-3 py-2", onClick: stop, children: [
|
|
2765
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "min-w-0 flex-1 truncate text-sm", children: file.name }),
|
|
2766
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
2767
|
+
"button",
|
|
2768
|
+
{
|
|
2769
|
+
type: "button",
|
|
2770
|
+
className: "inline-flex h-8 items-center rounded-full px-3 text-sm hover:bg-white/10",
|
|
2771
|
+
onClick: () => onDownload(file),
|
|
2772
|
+
children: labels.download
|
|
2773
|
+
}
|
|
2774
|
+
),
|
|
2775
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
2776
|
+
"button",
|
|
2777
|
+
{
|
|
2778
|
+
ref: closeRef,
|
|
2779
|
+
type: "button",
|
|
2780
|
+
"aria-label": labels.closePreview,
|
|
2781
|
+
className: "inline-flex size-8 items-center justify-center rounded-full hover:bg-white/10",
|
|
2782
|
+
onClick: onClose,
|
|
2783
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { "aria-hidden": "true", className: "size-4" })
|
|
2784
|
+
}
|
|
2785
|
+
)
|
|
2786
|
+
] }),
|
|
2787
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center px-4 pt-12 pb-16", children: kind === "image" ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
2788
|
+
"img",
|
|
2789
|
+
{
|
|
2790
|
+
src,
|
|
2791
|
+
alt: file.name,
|
|
2792
|
+
draggable: false,
|
|
2793
|
+
className: "max-h-full max-w-full cursor-zoom-in select-none object-contain",
|
|
2794
|
+
style: { transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})` },
|
|
2795
|
+
onClick: stop,
|
|
2796
|
+
onDoubleClick: (event) => {
|
|
2797
|
+
stop(event);
|
|
2798
|
+
zoom(scale === 1 ? 2 : 1);
|
|
2799
|
+
},
|
|
2800
|
+
onWheel: (event) => {
|
|
2801
|
+
event.preventDefault();
|
|
2802
|
+
stop(event);
|
|
2803
|
+
zoom(scale + (event.deltaY < 0 ? 0.25 : -0.25));
|
|
2804
|
+
},
|
|
2805
|
+
onPointerDown: (event) => {
|
|
2806
|
+
if (scale === 1) return;
|
|
2807
|
+
stop(event);
|
|
2808
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
2809
|
+
drag.current = { x: event.clientX, y: event.clientY, px: pan.x, py: pan.y };
|
|
2810
|
+
},
|
|
2811
|
+
onPointerMove: (event) => {
|
|
2812
|
+
if (!drag.current) return;
|
|
2813
|
+
setPan({
|
|
2814
|
+
x: drag.current.px + event.clientX - drag.current.x,
|
|
2815
|
+
y: drag.current.py + event.clientY - drag.current.y
|
|
2816
|
+
});
|
|
2817
|
+
},
|
|
2818
|
+
onPointerUp: () => {
|
|
2819
|
+
drag.current = null;
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
) : kind === "pdf" || kind === "text" ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
2823
|
+
"iframe",
|
|
2824
|
+
{
|
|
2825
|
+
src,
|
|
2826
|
+
title: file.name,
|
|
2827
|
+
className: "h-full w-full max-w-5xl bg-white",
|
|
2828
|
+
onClick: stop
|
|
2829
|
+
}
|
|
2830
|
+
) : kind === "video" ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
2831
|
+
"video",
|
|
2832
|
+
{
|
|
2833
|
+
controls: true,
|
|
2834
|
+
playsInline: true,
|
|
2835
|
+
preload: "metadata",
|
|
2836
|
+
src,
|
|
2837
|
+
className: "max-h-full max-w-full",
|
|
2838
|
+
onClick: stop,
|
|
2839
|
+
children: labels.previewUnavailable
|
|
2840
|
+
}
|
|
2841
|
+
) : kind === "audio" ? /* @__PURE__ */ jsxRuntime.jsx("audio", { controls: true, preload: "metadata", src, className: "w-full max-w-md", onClick: stop }) : /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-white/70", children: labels.previewUnavailable }) }),
|
|
2842
|
+
kind === "image" ? /* @__PURE__ */ jsxRuntime.jsxs(
|
|
2843
|
+
"div",
|
|
2844
|
+
{
|
|
2845
|
+
className: "absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full bg-black/70 px-1 py-1",
|
|
2846
|
+
onClick: stop,
|
|
2847
|
+
children: [
|
|
2848
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
2849
|
+
"button",
|
|
2850
|
+
{
|
|
2851
|
+
type: "button",
|
|
2852
|
+
"aria-label": labels.zoomOut,
|
|
2853
|
+
className: "inline-flex size-8 items-center justify-center rounded-full hover:bg-white/10",
|
|
2854
|
+
onClick: () => zoom(scale - 0.25),
|
|
2855
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Minus, { "aria-hidden": "true", className: "size-4" })
|
|
2856
|
+
}
|
|
2857
|
+
),
|
|
2858
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
2859
|
+
"button",
|
|
2860
|
+
{
|
|
2861
|
+
type: "button",
|
|
2862
|
+
"aria-label": labels.zoomFit,
|
|
2863
|
+
className: "min-w-14 px-2 text-xs tabular-nums",
|
|
2864
|
+
onClick: () => zoom(1),
|
|
2865
|
+
children: [
|
|
2866
|
+
Math.round(scale * 100),
|
|
2867
|
+
"%"
|
|
2868
|
+
]
|
|
2869
|
+
}
|
|
2870
|
+
),
|
|
2871
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
2872
|
+
"button",
|
|
2873
|
+
{
|
|
2874
|
+
type: "button",
|
|
2875
|
+
"aria-label": labels.zoomIn,
|
|
2876
|
+
className: "inline-flex size-8 items-center justify-center rounded-full hover:bg-white/10",
|
|
2877
|
+
onClick: () => zoom(scale + 0.25),
|
|
2878
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Plus, { "aria-hidden": "true", className: "size-4" })
|
|
2879
|
+
}
|
|
2880
|
+
)
|
|
2881
|
+
]
|
|
2882
|
+
}
|
|
2883
|
+
) : null
|
|
2884
|
+
]
|
|
2885
|
+
}
|
|
2886
|
+
);
|
|
2444
2887
|
}
|
|
2445
2888
|
function CreateFolderDialog({
|
|
2446
2889
|
open,
|