@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/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
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: cn("overflow-x-auto overscroll-x-contain", className), children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "min-w-[32rem] sm:min-w-0", children: [
313
- /* @__PURE__ */ jsxRuntime.jsxs(
314
- "div",
315
- {
316
- role: "row",
317
- className: cn(
318
- "grid items-center gap-3 border-b border-border px-3 py-2 font-mono text-[10px] font-medium uppercase tracking-[0.12em] text-muted-foreground",
319
- ROW_GRID
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
- /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true" }),
323
- /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true" }),
324
- /* @__PURE__ */ jsxRuntime.jsx(SortHeader, { col: "name", label: labels.sortName, sortKey, sortDirection, onSort: onSortChange }),
325
- /* @__PURE__ */ jsxRuntime.jsx(SortHeader, { col: "size", label: labels.sortSize, sortKey, sortDirection, onSort: onSortChange, className: "justify-end text-right" }),
326
- 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" })
327
- ]
328
- }
329
- ),
330
- /* @__PURE__ */ jsxRuntime.jsx("ul", { role: "list", children: files.map((file, idx) => /* @__PURE__ */ jsxRuntime.jsx(
331
- ExplorerListRow,
332
- {
333
- file,
334
- idx,
335
- selected: selectedIds.has(file.id),
336
- dragging: draggingIds?.has(file.id) ?? file.id === draggingId,
337
- dropTarget: file.id === dropTargetId && file.kind === "folder",
338
- isProtected: protectedIds?.has(file.id) ?? false,
339
- draggingId,
340
- dropTargetId,
341
- coarse,
342
- onSelect,
343
- onActivate,
344
- onContextMenu,
345
- onDragStart,
346
- onDragEnd,
347
- onDrop,
348
- onDragOverRow,
349
- renaming: file.id === renamingId,
350
- onRenameCommit,
351
- onRenameCancel
352
- },
353
- file.id
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
- "group grid items-center gap-3 border-b border-border/60 px-3 py-3.5 text-sm outline-none last:border-b-0 sm:py-2",
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
- return /* @__PURE__ */ jsxRuntime.jsx("ul", { role: "list", className: cn("grid grid-cols-3 gap-1.5 p-3 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8", className), children: files.map((file, idx) => /* @__PURE__ */ jsxRuntime.jsx(
534
- ExplorerGridItem,
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
- file,
537
- idx,
538
- selected: selectedIds.has(file.id),
539
- dragging: draggingIds?.has(file.id) ?? file.id === draggingId,
540
- dropTarget: file.id === dropTargetId && file.kind === "folder",
541
- isProtected: protectedIds?.has(file.id) ?? false,
542
- draggingId,
543
- dropTargetId,
544
- coarse,
545
- folderLabel: labels.folder,
546
- inUseLabel: labels.inUse,
547
- actionsLabel: labels.itemActions,
548
- onSelect,
549
- onActivate,
550
- onContextMenu,
551
- onDragStart,
552
- onDragEnd,
553
- onDrop,
554
- onDragOverRow,
555
- renaming: file.id === renamingId,
556
- onRenameCommit,
557
- onRenameCancel
558
- },
559
- file.id
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("li", { className: "relative", children: [
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 visible = items.slice(-6);
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: "ml-auto inline-flex size-7 items-center justify-center rounded-[10px] text-muted-foreground hover:bg-muted",
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
- ) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "ml-auto" })
1592
+ ) : null
1365
1593
  ] }),
1366
- /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "flex max-h-48 flex-col gap-1.5 overflow-y-auto", children: visible.map((item) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "min-w-0", children: [
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(target, next, (progress) => {
1417
- next.progress = progress;
1418
- const now = Date.now();
1419
- if (progress === 100 || now - lastPublish > 200) {
1420
- lastPublish = now;
1421
- publish();
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(ExplorerUploadStatus, { items: uploads.items, onDismiss: uploads.dismiss }) }),
1528
- [uploads.dismiss, uploads.items]
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.startsWith("image/") || ["png", "jpg", "jpeg", "gif", "webp"].includes(ext)) {
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", "mov"].includes(ext)) return "video";
2394
- if (mime.startsWith("text/") || ["txt", "csv"].includes(ext)) return "text";
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
- return /* @__PURE__ */ jsxRuntime.jsxs(Overlay, { open: true, onClose, className: "max-w-4xl", children: [
2407
- /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "truncate pr-6 text-base font-semibold", children: file.name }),
2408
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: file.mimeType || labels.unknownType }),
2409
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mt-3", children: kind === "image" ? /* @__PURE__ */ jsxRuntime.jsx(
2410
- "img",
2411
- {
2412
- src,
2413
- alt: file.name,
2414
- className: "max-h-[70vh] w-full rounded-[10px] object-contain"
2415
- }
2416
- ) : kind === "pdf" || kind === "text" ? /* @__PURE__ */ jsxRuntime.jsx(
2417
- "iframe",
2418
- {
2419
- src,
2420
- title: file.name,
2421
- className: "h-[70vh] w-full rounded-[10px] border border-border bg-background"
2422
- }
2423
- ) : kind === "video" ? /* @__PURE__ */ jsxRuntime.jsx(
2424
- "video",
2425
- {
2426
- controls: true,
2427
- playsInline: true,
2428
- preload: "metadata",
2429
- src,
2430
- className: "max-h-[70vh] w-full rounded-[10px] bg-foreground",
2431
- children: labels.previewUnavailable
2432
- }
2433
- ) : /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: labels.previewUnavailable }) }),
2434
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mt-4 flex justify-end", children: /* @__PURE__ */ jsxRuntime.jsx(
2435
- "button",
2436
- {
2437
- type: "button",
2438
- className: "inline-flex h-8 items-center rounded-[10px] border border-border px-3 text-sm",
2439
- onClick: () => onDownload(file),
2440
- children: labels.download
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,