mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-21 06:59:17 -04:00
* WIP * Some minor fixes for light theme - Fix `useIsDark` not reading the initial theme value (only reacting to theme changes) - Fix `Inspector` always showing a dark image when no item was selected - Fix `Thumb` video extension using black text on light theme * Improve form error messages - Fix `addLocationDialog` not registering the path input - Remove `@hookform/error-message` * Fix Dialog not respecting max-width - Fix ErrorMessage animation jumping * A lot of misc fixes - Implement an `useExplorerItemData` (cleaner fix for thumbnail flicker) - Fix broken image showing for `Thumb` due a rece condition when props are updated - Implement an `ExternalObject` component that hacks an alternative for `onLoad` and `onError` events for <object> - Fix `Overview` broken layout when `Inspector` is open and window is small - Improve `IndexerRuleEditor` UX in `AddLocationDialog` - Improve the way `IndexerRuleEditor` handles rules deletion - Fix `IndexerRuleEditor` closing the the new rule form even when the rule creation fails - Add an editable prop to `IndexerRuleEditor` to disable all editable functions - Fix `getIcon` fallbacking to Document instead of the dark version of an icon if it exists - Add some missing colors to white theme * Format * Fix Backup restore key dialog not resetting after error * Feedback * Format * Normalize imports * Fix ColorPicker export * Fix Thumb video ext not showing in MediaView with show square thumbnails - Fix AddLocationDialog Error resetting when changing IndexRules
441 lines
12 KiB
TypeScript
441 lines
12 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
|
import {
|
|
ColumnDef,
|
|
ColumnSizingState,
|
|
Row,
|
|
SortingState,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getSortedRowModel,
|
|
useReactTable
|
|
} from '@tanstack/react-table';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import byteSize from 'byte-size';
|
|
import clsx from 'clsx';
|
|
import dayjs from 'dayjs';
|
|
import { CaretDown, CaretUp } from 'phosphor-react';
|
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useKey, useOnWindowResize } from 'rooks';
|
|
import { ExplorerItem, FilePath, ObjectKind, isObject, isPath } from '@sd/client';
|
|
import {
|
|
getExplorerStore,
|
|
useDismissibleNoticeStore,
|
|
useExplorerStore,
|
|
useScrolled
|
|
} from '~/hooks';
|
|
import RenameTextBox from './File/RenameTextBox';
|
|
import FileThumb from './File/Thumb';
|
|
import { InfoPill } from './Inspector';
|
|
import { ViewItem } from './View';
|
|
import { useExplorerViewContext } from './ViewContext';
|
|
import { getExplorerItemData, getItemFilePath } from './util';
|
|
|
|
interface ListViewItemProps {
|
|
row: Row<ExplorerItem>;
|
|
index: number;
|
|
selected: boolean;
|
|
columnSizing: ColumnSizingState;
|
|
}
|
|
|
|
const ListViewItem = memo((props: ListViewItemProps) => {
|
|
return (
|
|
<ViewItem
|
|
data={props.row.original}
|
|
index={props.row.index}
|
|
className={clsx(
|
|
'flex w-full rounded-md border',
|
|
props.selected ? 'border-accent' : 'border-transparent',
|
|
props.index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
|
|
)}
|
|
contextMenuClassName="w-full"
|
|
>
|
|
<div role="row" className={'flex items-center'}>
|
|
{props.row.getVisibleCells().map((cell) => {
|
|
return (
|
|
<div
|
|
role="cell"
|
|
key={cell.id}
|
|
className={clsx(
|
|
'table-cell truncate px-4 text-xs text-ink-dull',
|
|
cell.column.columnDef.meta?.className
|
|
)}
|
|
style={{
|
|
width: cell.column.getSize()
|
|
}}
|
|
>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ViewItem>
|
|
);
|
|
});
|
|
|
|
export default () => {
|
|
const explorerStore = useExplorerStore();
|
|
const dismissibleNoticeStore = useDismissibleNoticeStore();
|
|
const { data, scrollRef, onLoadMore, hasNextPage, isFetchingNextPage } =
|
|
useExplorerViewContext();
|
|
const { isScrolled } = useScrolled(scrollRef, 5);
|
|
|
|
const [sized, setSized] = useState(false);
|
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
|
const [locked, setLocked] = useState(true);
|
|
|
|
const paddingX = 16;
|
|
const scrollBarWidth = 8;
|
|
|
|
const getObjectData = (data: ExplorerItem) => (isObject(data) ? data.item : data.item.object);
|
|
const getFileName = (path: FilePath) => `${path.name}${path.extension && `.${path.extension}`}`;
|
|
|
|
const columns = useMemo<ColumnDef<ExplorerItem>[]>(
|
|
() => [
|
|
{
|
|
header: 'Name',
|
|
minSize: 200,
|
|
meta: { className: '!overflow-visible !text-ink' },
|
|
accessorFn: (file) => {
|
|
const filePathData = getItemFilePath(file);
|
|
return filePathData && getFileName(filePathData);
|
|
},
|
|
cell: (cell) => {
|
|
const file = cell.row.original;
|
|
const filePathData = getItemFilePath(file);
|
|
const selected = explorerStore.selectedRowIndex === cell.row.index;
|
|
|
|
return (
|
|
<div className="relative flex items-center">
|
|
<div className="mr-[10px] flex h-6 w-12 shrink-0 items-center justify-center">
|
|
<FileThumb data={file} size={35} />
|
|
</div>
|
|
{filePathData && (
|
|
<RenameTextBox
|
|
filePathData={filePathData}
|
|
selected={selected}
|
|
activeClassName="absolute z-50 top-0.5 left-[58px] max-w-[calc(100%-60px)]"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
},
|
|
{
|
|
header: 'Type',
|
|
accessorFn: (file) => {
|
|
return isPath(file) && file.item.is_dir
|
|
? 'Folder'
|
|
: ObjectKind[getObjectData(file)?.kind || 0];
|
|
},
|
|
cell: (cell) => {
|
|
const file = cell.row.original;
|
|
return (
|
|
<InfoPill className="bg-app-button/50">
|
|
{isPath(file) && file.item.is_dir
|
|
? 'Folder'
|
|
: ObjectKind[getObjectData(file)?.kind || 0]}
|
|
</InfoPill>
|
|
);
|
|
}
|
|
},
|
|
{
|
|
header: 'Size',
|
|
size: 100,
|
|
accessorFn: (file) => byteSize(Number(getItemFilePath(file)?.size_in_bytes || 0))
|
|
},
|
|
{
|
|
header: 'Date Created',
|
|
accessorFn: (file) => dayjs(file.item.date_created).format('MMM Do YYYY'),
|
|
sortingFn: (a, b, name) => {
|
|
const aDate = a.original.item.date_created;
|
|
const bDate = b.original.item.date_created;
|
|
|
|
if (aDate === bDate) {
|
|
const desc = sorting.find((s) => s.id === name)?.desc;
|
|
|
|
const aPathData = getItemFilePath(a.original);
|
|
const bPathData = getItemFilePath(b.original);
|
|
|
|
const aName = aPathData ? getFileName(aPathData) : '';
|
|
const bName = bPathData ? getFileName(bPathData) : '';
|
|
|
|
return aName === bName
|
|
? 0
|
|
: aName > bName
|
|
? desc
|
|
? 1
|
|
: -1
|
|
: desc
|
|
? -1
|
|
: 1;
|
|
}
|
|
|
|
return aDate > bDate ? 1 : -1;
|
|
}
|
|
},
|
|
{
|
|
header: 'Content ID',
|
|
size: 180,
|
|
accessorFn: (file) => getExplorerItemData(file).casId
|
|
}
|
|
],
|
|
[explorerStore.selectedRowIndex, explorerStore.isRenaming, sorting]
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
defaultColumn: { minSize: 100 },
|
|
state: { columnSizing, sorting },
|
|
onColumnSizingChange: setColumnSizing,
|
|
onSortingChange: setSorting,
|
|
columnResizeMode: 'onChange',
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel()
|
|
});
|
|
|
|
const tableLength = table.getTotalSize();
|
|
const { rows } = table.getRowModel();
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: rows.length,
|
|
getScrollElement: () => scrollRef.current,
|
|
estimateSize: () => 45,
|
|
paddingStart: 12,
|
|
paddingEnd: 12,
|
|
overscan: !dismissibleNoticeStore.listView ? 5 : 1
|
|
});
|
|
|
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
|
|
|
useEffect(() => {
|
|
const lastRow = virtualRows[virtualRows.length - 1];
|
|
if (lastRow?.index === rows.length - 1 && hasNextPage && !isFetchingNextPage) {
|
|
onLoadMore?.();
|
|
}
|
|
}, [hasNextPage, onLoadMore, isFetchingNextPage, virtualRows, rows.length]);
|
|
|
|
function handleResize() {
|
|
if (scrollRef.current) {
|
|
if (locked && Object.keys(columnSizing).length > 0) {
|
|
table.setColumnSizing((sizing) => {
|
|
const scrollWidth = scrollRef.current?.offsetWidth;
|
|
const nameWidth = sizing.Name;
|
|
return {
|
|
...sizing,
|
|
...(scrollWidth && nameWidth
|
|
? {
|
|
Name:
|
|
nameWidth +
|
|
scrollWidth -
|
|
paddingX * 2 -
|
|
scrollBarWidth -
|
|
tableLength
|
|
}
|
|
: {})
|
|
};
|
|
});
|
|
} else {
|
|
const scrollWidth = scrollRef.current.offsetWidth;
|
|
const tableWidth = tableLength;
|
|
if (Math.abs(scrollWidth - tableWidth) < 10) {
|
|
setLocked(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Measure initial column widths
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
const columns = table.getAllColumns();
|
|
const sizings = columns.reduce(
|
|
(sizings, column) =>
|
|
column.id === 'Name' ? sizings : { ...sizings, [column.id]: column.getSize() },
|
|
{} as ColumnSizingState
|
|
);
|
|
const scrollWidth = scrollRef.current.offsetWidth;
|
|
const sizingsSum = Object.values(sizings).reduce((a, b) => a + b, 0);
|
|
const nameWidth = scrollWidth - paddingX * 2 - scrollBarWidth - sizingsSum;
|
|
table.setColumnSizing({ ...sizings, Name: nameWidth });
|
|
setSized(true);
|
|
}
|
|
}, []);
|
|
|
|
// Resize view on window resize
|
|
useOnWindowResize(handleResize);
|
|
|
|
const lastSelectedIndex = useRef(explorerStore.selectedRowIndex);
|
|
|
|
// Resize view on item selection/deselection
|
|
useEffect(() => {
|
|
const { selectedRowIndex } = explorerStore;
|
|
|
|
if (
|
|
explorerStore.showInspector &&
|
|
typeof lastSelectedIndex.current !== typeof selectedRowIndex
|
|
)
|
|
handleResize();
|
|
|
|
lastSelectedIndex.current = selectedRowIndex;
|
|
}, [explorerStore.selectedRowIndex]);
|
|
|
|
// Resize view on inspector toggle
|
|
useEffect(() => {
|
|
if (explorerStore.selectedRowIndex !== null) handleResize();
|
|
}, [explorerStore.showInspector]);
|
|
|
|
// Force recalculate range
|
|
// https://github.com/TanStack/virtual/issues/485
|
|
useMemo(() => {
|
|
// @ts-ignore
|
|
rowVirtualizer.calculateRange();
|
|
}, [rows.length, rowVirtualizer]);
|
|
|
|
// Select item with arrow up key
|
|
useKey(
|
|
'ArrowUp',
|
|
(e) => {
|
|
e.preventDefault();
|
|
|
|
const { selectedRowIndex } = explorerStore;
|
|
|
|
if (selectedRowIndex === null) return;
|
|
|
|
if (selectedRowIndex > 0) {
|
|
const currentIndex = rows.findIndex((row) => row.index === selectedRowIndex);
|
|
const newIndex = rows[currentIndex - 1]?.index;
|
|
|
|
if (newIndex !== undefined) getExplorerStore().selectedRowIndex = newIndex;
|
|
}
|
|
},
|
|
{ when: !explorerStore.isRenaming }
|
|
);
|
|
|
|
// Select item with arrow down key
|
|
useKey(
|
|
'ArrowDown',
|
|
(e) => {
|
|
e.preventDefault();
|
|
|
|
const { selectedRowIndex } = explorerStore;
|
|
|
|
if (selectedRowIndex === null) return;
|
|
|
|
if (selectedRowIndex !== data.length - 1) {
|
|
const currentIndex = rows.findIndex((row) => row.index === selectedRowIndex);
|
|
const newIndex = rows[currentIndex + 1]?.index;
|
|
|
|
if (newIndex !== undefined) getExplorerStore().selectedRowIndex = newIndex;
|
|
}
|
|
},
|
|
{ when: !explorerStore.isRenaming }
|
|
);
|
|
|
|
if (!sized) return null;
|
|
return (
|
|
<div role="table" className="table w-full overflow-x-auto">
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={clsx(
|
|
'sticky top-0 z-20 table-header-group',
|
|
isScrolled && 'top-bar-blur !bg-app/90'
|
|
)}
|
|
>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<div
|
|
role="rowheader"
|
|
key={headerGroup.id}
|
|
className="flex border-b border-app-line/50"
|
|
>
|
|
{headerGroup.headers.map((header, i) => {
|
|
const size = header.column.getSize();
|
|
return (
|
|
<div
|
|
role="columnheader"
|
|
key={header.id}
|
|
className="relative truncate px-4 py-2 text-xs first:pl-24"
|
|
style={{
|
|
width:
|
|
i === 0
|
|
? size + paddingX
|
|
: i === headerGroup.headers.length - 1
|
|
? size - paddingX
|
|
: size
|
|
}}
|
|
onClick={header.column.getToggleSortingHandler()}
|
|
>
|
|
{header.isPlaceholder ? null : (
|
|
<div className={clsx('flex items-center')}>
|
|
{flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext()
|
|
)}
|
|
<div className="flex-1" />
|
|
|
|
{{
|
|
asc: <CaretUp className="text-ink-faint" />,
|
|
desc: <CaretDown className="text-ink-faint" />
|
|
}[header.column.getIsSorted() as string] ?? null}
|
|
|
|
{(i !== headerGroup.headers.length - 1 ||
|
|
(i === headerGroup.headers.length - 1 &&
|
|
!locked)) && (
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
onMouseDown={(e) => {
|
|
setLocked(false);
|
|
header.getResizeHandler()(e);
|
|
}}
|
|
onTouchStart={header.getResizeHandler()}
|
|
className="absolute right-0 h-[70%] w-2 cursor-col-resize border-r border-app-line/50"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div role="rowgroup" className="table-row-group">
|
|
<div
|
|
className="relative"
|
|
style={{
|
|
height: `${rowVirtualizer.getTotalSize()}px`
|
|
}}
|
|
>
|
|
{virtualRows.map((virtualRow) => {
|
|
const row = rows[virtualRow.index]!;
|
|
const selected = explorerStore.selectedRowIndex === row.index;
|
|
|
|
return (
|
|
<div
|
|
key={row.id}
|
|
className={clsx(
|
|
'absolute left-0 top-0 flex w-full pl-4 pr-3',
|
|
explorerStore.isRenaming && selected && 'z-10'
|
|
)}
|
|
style={{
|
|
height: `${virtualRow.size}px`,
|
|
transform: `translateY(${virtualRow.start}px)`
|
|
}}
|
|
>
|
|
<ListViewItem
|
|
row={row}
|
|
index={virtualRow.index}
|
|
selected={selected}
|
|
columnSizing={columnSizing}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|