Files
spacedrive/interface/app/$libraryId/Explorer/ListView.tsx
Vítor Vasconcellos 0d3805339e [ENG-591] - Fix some funky behaviors (#827)
* 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
2023-05-20 03:11:10 +00:00

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>
);
};