[ENG 655] Explorer restructure (#858)

* wip

* wip 2

* Grid list single selection

* core & pnpm-lock

* Merge branch 'main'

Conflicts:
	interface/app/$libraryId/Explorer/index.tsx

* missing import from merge

* fix total_orphan_paths bug

* add top bar context

* missing pieces of merge

* missing pieces of merge

* missing divs

* Fill fallback value - was causing null error of page

* spelling fixes

* notice light theme, list view update, other explorer updates

* Update pnpm-lock

* Remove procedure

* fix light menu ink color

* fix list view scrolled state

* Change layout default

* Remove unused imports

* remove keys

* empty notice & context menu overview

* Fix prevent selection while context menu is up

* Fix scroll with keys

* Empty notice icon

* Add light icons

* Context menu and fixed list view scroll

* Fix name column sizing

* top/bottom scroll position

* Tag assign only when objectData

* Fix list view locked state

* fix ci

* shamefully ignore eslint

---------

Co-authored-by: Jamie Pine <ijamespine@me.com>
Co-authored-by: ameer2468 <33054370+ameer2468@users.noreply.github.com>
Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com>
Co-authored-by: Utku Bakir <74243531+utkubakir@users.noreply.github.com>
This commit is contained in:
nikec
2023-06-06 08:55:56 +02:00
committed by GitHub
parent 57e1440c96
commit ff9515bdb4
39 changed files with 2159 additions and 1420 deletions

View File

@@ -28,4 +28,4 @@ export const env = createEnv({
// In dev or in eslint disable checking.
// Kinda sucks for in dev but you don't need the whole setup to change the docs.
skipValidation: process.env.VERCEL !== '1'
});
});

View File

@@ -86,7 +86,7 @@ export function ErrorPage({
</p>
<Button
variant="colored"
className="max-w-xs mt-4 bg-red-500 border-transparent"
className="mt-4 max-w-xs border-transparent bg-red-500"
onClick={() => {
// @ts-expect-error
window.__TAURI_INVOKE__('reset_spacedrive');

View File

@@ -1,22 +1,45 @@
import { Collection, Image, Video } from '@sd/assets/icons';
import {
Collection,
Collection_Light,
Image,
Image_Light,
Video,
Video_Light
} from '@sd/assets/icons';
import { useTheme } from '@tanstack/react-query-devtools/build/lib/theme';
import clsx from 'clsx';
import { ReactNode } from 'react';
import DismissibleNotice from '~/components/DismissibleNotice';
import { useIsDark } from '~/hooks';
import { dismissibleNoticeStore } from '~/hooks/useDismissibleNoticeStore';
import { ExplorerLayoutMode, useExplorerStore } from '~/hooks/useExplorerStore';
const MediaViewIcon = () => (
<div className="relative ml-3 mr-10 h-14 w-14 shrink-0">
<img src={Image} className="absolute -top-1 left-6 h-14 w-14 rotate-6 overflow-hidden" />
<img src={Video} className="absolute top-2 z-10 h-14 w-14 -rotate-6 overflow-hidden" />
</div>
);
const MediaViewIcon = () => {
const isDark = useIsDark();
const CollectionIcon = () => (
<div className="ml-3 mr-4 h-14 w-14 shrink-0">
<img src={Collection} />
</div>
);
return (
<div className="relative ml-3 mr-10 h-14 w-14 shrink-0">
<img
src={isDark ? Image : Image_Light}
className="absolute -top-1 left-6 h-14 w-14 rotate-6 overflow-hidden"
/>
<img
src={isDark ? Video : Video_Light}
className="absolute top-2 z-10 h-14 w-14 -rotate-6 overflow-hidden"
/>
</div>
);
};
const CollectionIcon = () => {
const isDark = useIsDark();
return (
<div className="ml-3 mr-4 h-14 w-14 shrink-0">
<img src={isDark ? Collection : Collection_Light} />
</div>
);
};
interface Notice {
key: keyof typeof dismissibleNoticeStore;
@@ -66,7 +89,7 @@ export default () => {
}
icon={notice.icon}
description={notice.description}
className={clsx('m-5', layoutMode === 'grid' && 'ml-1')}
className="m-5"
storageKey={notice.key}
/>
);

View File

@@ -1,4 +1,3 @@
import clsx from 'clsx';
import {
ArrowBendUpRight,
Copy,
@@ -13,7 +12,6 @@ import {
Trash,
TrashSimple
} from 'phosphor-react';
import { PropsWithChildren } from 'react';
import {
ExplorerItem,
isObject,
@@ -34,12 +32,11 @@ import DeleteDialog from './DeleteDialog';
import EncryptDialog from './EncryptDialog';
import EraseDialog from './EraseDialog';
interface Props extends PropsWithChildren {
data: ExplorerItem;
className?: string;
interface Props {
data?: ExplorerItem;
}
export default ({ data, className, ...props }: Props) => {
export default ({ data }: Props) => {
const store = useExplorerStore();
const [params] = useExplorerSearchParams();
const objectData = data ? (isObject(data) ? data.item : data.item.object) : null;
@@ -54,225 +51,206 @@ export default ({ data, className, ...props }: Props) => {
const generateThumbnails = useLibraryMutation('jobs.generateThumbsForLocation');
const fullRescan = useLibraryMutation('locations.fullRescan');
if (!data) return null;
return (
<div onClick={(e) => e.stopPropagation()} className={clsx('flex', className)}>
<ContextMenu.Root trigger={props.children}>
<OpenOrDownloadOptions data={data} />
<>
<OpenOrDownloadOptions data={data} />
<ContextMenu.Separator />
<ContextMenu.Separator />
{!store.showInspector && (
<>
<ContextMenu.Item
label="Details"
keybind="⌘I"
// icon={Sidebar}
onClick={() => (getExplorerStore().showInspector = true)}
/>
<ContextMenu.Separator />
</>
)}
<OpenInNativeExplorer />
<ContextMenu.Item
label="Rename"
keybind="Enter"
onClick={() => (getExplorerStore().isRenaming = true)}
/>
{data.type == 'Path' && data.item.object && data.item.object.date_accessed && (
{!store.showInspector && (
<>
<ContextMenu.Item
label="Remove from recents"
onClick={() =>
data.item.object_id && removeFromRecents.mutate(data.item.object_id)
}
label="Details"
keybind="⌘I"
// icon={Sidebar}
onClick={() => (getExplorerStore().showInspector = true)}
/>
)}
<ContextMenu.Separator />
</>
)}
<OpenInNativeExplorer />
<ContextMenu.Item
label="Rename"
keybind="Enter"
onClick={() => (getExplorerStore().isRenaming = true)}
/>
{data.type == 'Path' && data.item.object && data.item.object.date_accessed && (
<ContextMenu.Item
label="Cut"
keybind="⌘X"
label="Remove from recents"
onClick={() =>
data.item.object_id && removeFromRecents.mutate(data.item.object_id)
}
/>
)}
<ContextMenu.Item
label="Cut"
keybind="⌘X"
onClick={() => {
if (params.path === undefined) return;
getExplorerStore().cutCopyState = {
sourcePath: params.path,
sourceLocationId: store.locationId!,
sourcePathId: data.item.id,
actionType: 'Cut',
active: true
};
}}
icon={Scissors}
/>
<ContextMenu.Item
label="Copy"
keybind="⌘C"
onClick={() => {
if (params.path === undefined) return;
getExplorerStore().cutCopyState = {
sourcePath: params.path,
sourceLocationId: store.locationId!,
sourcePathId: data.item.id,
actionType: 'Copy',
active: true
};
}}
icon={Copy}
/>
<ContextMenu.Item
label="Duplicate"
keybind="⌘D"
onClick={() => {
if (params.path === undefined) return;
copyFiles.mutate({
source_location_id: store.locationId!,
source_path_id: data.item.id,
target_location_id: store.locationId!,
target_path: params.path,
target_file_name_suffix: ' copy'
});
}}
/>
<ContextMenu.Item
label="Deselect"
hidden={!store.cutCopyState.active}
onClick={() => {
getExplorerStore().cutCopyState = {
...store.cutCopyState,
active: false
};
}}
icon={FileX}
/>
<ContextMenu.Separator />
<ContextMenu.Item
label="Share"
icon={Share}
onClick={(e) => {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}}
/>
<ContextMenu.Separator />
{objectData && (
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objectId={objectData.id} />
</ContextMenu.SubMenu>
)}
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
<ContextMenu.Item
label="Encrypt"
icon={LockSimple}
keybind="⌘E"
onClick={() => {
if (params.path === undefined) return;
getExplorerStore().cutCopyState = {
sourcePath: params.path,
sourceLocationId: store.locationId!,
sourcePathId: data.item.id,
actionType: 'Cut',
active: true
};
}}
icon={Scissors}
/>
<ContextMenu.Item
label="Copy"
keybind="⌘C"
onClick={() => {
if (params.path === undefined) return;
getExplorerStore().cutCopyState = {
sourcePath: params.path,
sourceLocationId: store.locationId!,
sourcePathId: data.item.id,
actionType: 'Copy',
active: true
};
}}
icon={Copy}
/>
<ContextMenu.Item
label="Duplicate"
keybind="⌘D"
onClick={() => {
if (params.path === undefined) return;
copyFiles.mutate({
source_location_id: store.locationId!,
source_path_id: data.item.id,
target_location_id: store.locationId!,
target_path: params.path,
target_file_name_suffix: ' copy'
});
}}
/>
<ContextMenu.Item
label="Deselect"
hidden={!store.cutCopyState.active}
onClick={() => {
getExplorerStore().cutCopyState = {
...store.cutCopyState,
active: false
};
}}
icon={FileX}
/>
<ContextMenu.Separator />
<ContextMenu.Item
label="Share"
icon={Share}
onClick={(e) => {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}}
/>
<ContextMenu.Separator />
{objectData && (
<ContextMenu.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objectId={objectData.id} />
</ContextMenu.SubMenu>
)}
<ContextMenu.SubMenu label="More actions..." icon={Plus}>
<ContextMenu.Item
label="Encrypt"
icon={LockSimple}
keybind="⌘E"
onClick={() => {
if (keyManagerUnlocked && hasMountedKeys) {
dialogManager.create((dp) => (
<EncryptDialog
{...dp}
location_id={store.locationId!}
path_id={data.item.id}
/>
));
} else if (!keyManagerUnlocked) {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
} else if (!hasMountedKeys) {
showAlertDialog({
title: 'No mounted keys',
value: 'No mounted keys were found. Please mount a key and try again.'
});
}
}}
/>
{/* should only be shown if the file is a valid spacedrive-encrypted file (preferably going from the magic bytes) */}
<ContextMenu.Item
label="Decrypt"
icon={LockSimpleOpen}
keybind="⌘D"
onClick={() => {
if (keyManagerUnlocked) {
dialogManager.create((dp) => (
<DecryptDialog
{...dp}
location_id={store.locationId!}
path_id={data.item.id}
/>
));
} else {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
}
}}
/>
<ContextMenu.Item label="Compress" icon={Package} keybind="⌘B" />
<ContextMenu.SubMenu label="Convert to" icon={ArrowBendUpRight}>
<ContextMenu.Item label="PNG" />
<ContextMenu.Item label="WebP" />
</ContextMenu.SubMenu>
<ContextMenu.Item
onClick={() => {
fullRescan.mutate(getExplorerStore().locationId!);
}}
label="Rescan Directory"
icon={Package}
/>
<ContextMenu.Item
onClick={() => {
generateThumbnails.mutate({
id: getExplorerStore().locationId!,
path: '/'
});
}}
label="Regen Thumbnails"
icon={Package}
/>
<ContextMenu.Item
variant="danger"
label="Secure delete"
icon={TrashSimple}
onClick={() => {
if (keyManagerUnlocked && hasMountedKeys) {
dialogManager.create((dp) => (
<EraseDialog
<EncryptDialog
{...dp}
location_id={getExplorerStore().locationId!}
location_id={store.locationId!}
path_id={data.item.id}
/>
));
}}
/>
</ContextMenu.SubMenu>
<ContextMenu.Separator />
} else if (!keyManagerUnlocked) {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
} else if (!hasMountedKeys) {
showAlertDialog({
title: 'No mounted keys',
value: 'No mounted keys were found. Please mount a key and try again.'
});
}
}}
/>
{/* should only be shown if the file is a valid spacedrive-encrypted file (preferably going from the magic bytes) */}
<ContextMenu.Item
label="Decrypt"
icon={LockSimpleOpen}
keybind="⌘D"
onClick={() => {
if (keyManagerUnlocked) {
dialogManager.create((dp) => (
<DecryptDialog
{...dp}
location_id={store.locationId!}
path_id={data.item.id}
/>
));
} else {
showAlertDialog({
title: 'Key manager locked',
value: 'The key manager is currently locked. Please unlock it and try again.'
});
}
}}
/>
<ContextMenu.Item label="Compress" icon={Package} keybind="⌘B" />
<ContextMenu.SubMenu label="Convert to" icon={ArrowBendUpRight}>
<ContextMenu.Item label="PNG" />
<ContextMenu.Item label="WebP" />
</ContextMenu.SubMenu>
<ContextMenu.Item
onClick={() => {
fullRescan.mutate(getExplorerStore().locationId!);
}}
label="Rescan Directory"
icon={Package}
/>
<ContextMenu.Item
onClick={() => {
generateThumbnails.mutate({
id: getExplorerStore().locationId!,
path: '/'
});
}}
label="Regen Thumbnails"
icon={Package}
/>
<ContextMenu.Item
icon={Trash}
label="Delete"
variant="danger"
keybind="⌘DEL"
label="Secure delete"
icon={TrashSimple}
onClick={() => {
dialogManager.create((dp) => (
<DeleteDialog
<EraseDialog
{...dp}
location_id={getExplorerStore().locationId!}
path_id={data.item.id}
@@ -280,8 +258,26 @@ export default ({ data, className, ...props }: Props) => {
));
}}
/>
</ContextMenu.Root>
</div>
</ContextMenu.SubMenu>
<ContextMenu.Separator />
<ContextMenu.Item
icon={Trash}
label="Delete"
variant="danger"
keybind="⌘DEL"
onClick={() => {
dialogManager.create((dp) => (
<DeleteDialog
{...dp}
location_id={getExplorerStore().locationId!}
path_id={data.item.id}
/>
));
}}
/>
</>
);
};

View File

@@ -9,9 +9,17 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
filePathData: FilePath;
selected: boolean;
activeClassName?: string;
disabled?: boolean;
}
export default ({ filePathData, selected, className, activeClassName, ...props }: Props) => {
export default ({
filePathData,
selected,
className,
activeClassName,
disabled,
...props
}: Props) => {
const explorerStore = useExplorerStore();
const os = useOperatingSystem();
@@ -129,9 +137,22 @@ export default ({ filePathData, selected, className, activeClassName, ...props }
if (allowRename) {
e.preventDefault();
blur();
} else if (selected) setAllowRename(true);
} else if (selected && !disabled) setAllowRename(true);
});
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
blur();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref]);
return (
<div
ref={ref}
@@ -139,13 +160,16 @@ export default ({ filePathData, selected, className, activeClassName, ...props }
contentEditable={allowRename}
suppressContentEditableWarning
className={clsx(
'cursor-default overflow-y-auto truncate rounded-md px-1.5 py-px text-xs',
allowRename && ['whitespace-normal bg-app', activeClassName],
'cursor-default overflow-y-auto truncate rounded-md px-1.5 py-px text-xs text-ink',
allowRename && [
'whitespace-normal bg-app outline-none ring-2 ring-accent-deep',
activeClassName
],
className
)}
onClick={(e) => {
if (selected || allowRename) e.stopPropagation();
if (selected) setAllowRename(true);
if (selected && !disabled) setAllowRename(true);
}}
onBlur={() => {
rename();

View File

@@ -51,13 +51,13 @@ const Thumbnail = memo(
videoBarsSize
? size && size.height >= size.width
? {
borderLeftWidth: videoBarsSize,
borderRightWidth: videoBarsSize
}
borderLeftWidth: videoBarsSize,
borderRightWidth: videoBarsSize
}
: {
borderTopWidth: videoBarsSize,
borderBottomWidth: videoBarsSize
}
borderTopWidth: videoBarsSize,
borderBottomWidth: videoBarsSize
}
: {}
}
onLoad={props.onLoad}
@@ -67,6 +67,7 @@ const Thumbnail = memo(
}}
decoding={props.decoding}
className={props.className}
draggable={false}
/>
{videoExtension && (
<div
@@ -74,11 +75,11 @@ const Thumbnail = memo(
props.cover
? {}
: size
? {
? {
marginTop: Math.floor(size.height / 2) - 2,
marginLeft: Math.floor(size.width / 2) - 2
}
: { display: 'none' }
}
: { display: 'none' }
}
className={clsx(
props.cover
@@ -148,7 +149,7 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
library.uuid,
locationId,
props.data.item.id,
// Workaround Linux webview not supporting playng video and audio through custom protocol urls
// Workaround Linux webview not supporting playing video and audio through custom protocol urls
kind == 'Video' || kind == 'Audio'
)
);
@@ -199,9 +200,9 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
className={clsx(
'relative flex shrink-0 items-center justify-center',
size &&
kind !== 'Video' &&
thumbType !== ThumbType.Icon &&
'border-2 border-transparent',
kind !== 'Video' &&
thumbType !== ThumbType.Icon &&
'border-2 border-transparent',
size || ['h-full', cover ? 'w-full overflow-hidden' : 'w-[90%]'],
props.className
)}
@@ -248,6 +249,7 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
)}
playsInline
onLoadedData={onLoad}
draggable={false}
>
<p>Video preview is not supported.</p>
</video>
@@ -260,6 +262,7 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
onLoad={onLoad}
decoding={size ? 'async' : 'sync'}
className={clsx(childClassName, props.className)}
draggable={false}
/>
{props.mediaControls && (
<audio
@@ -296,9 +299,9 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
'shadow shadow-black/30'
],
size &&
(kind === 'Video'
? 'border-x-0 border-black'
: size > 60 && 'border-2 border-app-line'),
(kind === 'Video'
? 'border-x-0 border-black'
: size > 60 && 'border-2 border-app-line'),
props.className
)}
crossOrigin={ThumbType.Original && 'anonymous'} // Here it is ok, because it is not a react attr
@@ -321,6 +324,7 @@ function FileThumb({ size, cover, ...props }: ThumbProps) {
onError={() => setLoaded(false)}
decoding={size ? 'async' : 'sync'}
className={clsx(childClassName, props.className)}
draggable={false}
/>
);
}

View File

@@ -1,224 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useKey, useOnWindowResize } from 'rooks';
import { ExplorerItem, formatBytes } from '@sd/client';
import { getExplorerStore, useExplorerStore } from '~/hooks';
import RenameTextBox from './File/RenameTextBox';
import FileThumb from './File/Thumb';
import { ViewItem } from './View';
import { useExplorerViewContext } from './ViewContext';
import { getItemFilePath } from './util';
interface GridViewItemProps {
data: ExplorerItem;
selected: boolean;
index: number;
}
const GridViewItem = memo(({ data, selected, index, ...props }: GridViewItemProps) => {
const filePathData = data ? getItemFilePath(data) : null;
const explorerStore = useExplorerStore();
return (
<ViewItem
data={data}
index={index}
draggable
style={{ width: explorerStore.gridItemSize }}
{...props}
>
<div
style={{
width: explorerStore.gridItemSize,
height: explorerStore.gridItemSize
}}
className={clsx(
'mb-1 flex items-center justify-center justify-items-center rounded-lg border-2 border-transparent text-center active:translate-y-[1px]',
{
'bg-app-selectedItem': selected
}
)}
>
<FileThumb data={data} size={explorerStore.gridItemSize} />
</div>
<div className="flex flex-col justify-center">
{filePathData && (
<RenameTextBox
filePathData={filePathData}
selected={selected}
className={clsx(
'text-center font-medium',
selected && 'bg-accent text-white'
)}
style={{
maxHeight: explorerStore.gridItemSize / 3
}}
/>
)}
{explorerStore.showBytesInGridView &&
(!explorerStore.isRenaming || (explorerStore.isRenaming && !selected)) && (
<span
className={clsx(
'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-tiny text-ink-dull '
)}
>
{formatBytes(Number(filePathData?.size_in_bytes || 0))}
</span>
)}
</div>
</ViewItem>
);
});
const LEFT_PADDING = 14;
export default () => {
const explorerStore = useExplorerStore();
const { data, scrollRef, onLoadMore, hasNextPage, isFetchingNextPage } =
useExplorerViewContext();
const [width, setWidth] = useState(0);
// Virtualizer count calculation
const amountOfColumns = Math.floor(width / explorerStore.gridItemSize) || 1;
const amountOfRows = Math.ceil(data.length / amountOfColumns);
// Virtualizer item size calculation
const gridTextAreaHeight =
explorerStore.gridItemSize / 4 + (explorerStore.showBytesInGridView ? 20 : 0);
const itemSize = explorerStore.gridItemSize + gridTextAreaHeight;
const rowVirtualizer = useVirtualizer({
count: amountOfRows,
getScrollElement: () => scrollRef.current,
estimateSize: () => itemSize,
measureElement: () => itemSize,
paddingStart: 12,
paddingEnd: 12
});
const virtualRows = rowVirtualizer.getVirtualItems();
useEffect(() => {
const lastRow = virtualRows[virtualRows.length - 1];
if (lastRow?.index === amountOfRows - 1 && hasNextPage && !isFetchingNextPage) {
onLoadMore?.();
}
}, [hasNextPage, onLoadMore, isFetchingNextPage, virtualRows, data.length]);
function handleWindowResize() {
if (scrollRef.current) {
setWidth(scrollRef.current.offsetWidth - LEFT_PADDING);
}
}
// Resize view on initial render
useEffect(() => handleWindowResize(), []);
// Resize view on window resize
useOnWindowResize(handleWindowResize);
const lastSelectedIndex = useRef(explorerStore.selectedRowIndex);
// Resize view on item selection/deselection
useEffect(() => {
const { selectedRowIndex } = explorerStore;
if (
explorerStore.showInspector &&
typeof lastSelectedIndex.current !== typeof selectedRowIndex
) {
handleWindowResize();
}
lastSelectedIndex.current = selectedRowIndex;
}, [explorerStore.selectedRowIndex]);
// Resize view on inspector toggle
useEffect(() => {
if (explorerStore.selectedRowIndex !== null) handleWindowResize();
}, [explorerStore.showInspector]);
// Measure item on grid item size change
useEffect(() => {
rowVirtualizer.measure();
}, [explorerStore.showBytesInGridView, explorerStore.gridItemSize, rowVirtualizer]);
// Force recalculate range
// https://github.com/TanStack/virtual/issues/485
useMemo(() => {
// @ts-ignore
rowVirtualizer.calculateRange();
}, [amountOfRows, rowVirtualizer]);
// Select item with arrow up key
useKey(
'ArrowUp',
(e) => {
e.preventDefault();
const { selectedRowIndex } = explorerStore;
if (selectedRowIndex === null) return;
getExplorerStore().selectedRowIndex = Math.max(selectedRowIndex - 1, 0);
},
{ when: !explorerStore.isRenaming }
);
// Select item with arrow down key
useKey(
'ArrowDown',
(e) => {
e.preventDefault();
const { selectedRowIndex } = explorerStore;
if (selectedRowIndex === null) return;
getExplorerStore().selectedRowIndex = Math.min(selectedRowIndex + 1, data.length - 1);
},
{ when: !explorerStore.isRenaming }
);
if (!width) return null;
return (
<div
className="relative"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
marginLeft: `${LEFT_PADDING - 4}px`
}}
>
{virtualRows.map((virtualRow) => (
<div
key={virtualRow.key}
className="absolute left-0 top-0 flex w-full"
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`
}}
>
{[...Array(amountOfColumns)].map((_, i) => {
const index = virtualRow.index * amountOfColumns + i;
const item = data[index];
const isSelected = explorerStore.selectedRowIndex === index;
if (!item) return null;
return (
<GridViewItem
key={item.item.id}
data={item}
selected={isSelected}
index={index}
/>
);
})}
</div>
))}
</div>
);
};

View File

@@ -3,7 +3,7 @@ import { Image, Image_Light } from '@sd/assets/icons';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { Barcode, CircleWavyCheck, Clock, Cube, Hash, Link, Lock, Snowflake } from 'phosphor-react';
import { ComponentProps, useEffect, useState } from 'react';
import { ComponentProps, HTMLAttributes, useEffect, useState } from 'react';
import {
ExplorerItem,
Location,
@@ -36,12 +36,13 @@ const InspectorIcon = ({ component: Icon, ...props }: any) => (
<Icon weight="bold" {...props} className={clsx('mr-2 shrink-0', props.className)} />
);
interface Props extends Omit<ComponentProps<'div'>, 'onScroll'> {
interface Props extends HTMLAttributes<HTMLDivElement> {
context?: Location | Tag;
data: ExplorerItem | null;
data?: ExplorerItem;
showThumbnail?: boolean;
}
export const Inspector = ({ data, context, className, ...elementProps }: Props) => {
export const Inspector = ({ data, context, showThumbnail = true, ...props }: Props) => {
const isDark = useIsDark();
const objectData = data ? getItemObject(data) : null;
const filePathData = data ? getItemFilePath(data) : null;
@@ -73,23 +74,12 @@ export const Inspector = ({ data, context, className, ...elementProps }: Props)
const pub_id = fullObjectData?.data?.pub_id.map((n: number) => n.toString(16)).join('');
return (
<div
{...elementProps}
className={clsx(
`custom-scroll inspector-scroll h-screen w-full overflow-x-hidden pb-4 pl-1.5 pr-1`,
className
)}
style={{ paddingTop: TOP_BAR_HEIGHT + 12 }}
>
<div {...props}>
{item ? (
<>
{explorerStore.layoutMode !== 'media' && (
<div
className={clsx(
'mb-[10px] flex h-[240] w-full items-center justify-center overflow-hidden'
)}
>
<FileThumb loadOriginal size={240} data={data} />
{showThumbnail && (
<div className="mb-2 aspect-square">
<FileThumb loadOriginal size={null} data={data} className="mx-auto" />
</div>
)}
<div className="flex w-full select-text flex-col overflow-hidden rounded-lg border border-app-line bg-app-box py-0.5 shadow-app-shade/10">

View File

@@ -1,444 +0,0 @@
/* 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>
);
});
interface Props {
listViewHeadersClassName?: string;
}
export default (props: Props) => {
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',
props.listViewHeadersClassName
)}
>
{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>
);
};

View File

@@ -1,224 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { ArrowsOutSimple } from 'phosphor-react';
import { memo, useEffect, useMemo, useState } from 'react';
import React from 'react';
import { useKey, useOnWindowResize } from 'rooks';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import { getExplorerStore, useDismissibleNoticeStore, useExplorerStore } from '~/hooks';
import FileThumb from './File/Thumb';
import { ViewItem } from './View';
import { useExplorerViewContext } from './ViewContext';
interface MediaViewItemProps {
data: ExplorerItem;
index: number;
}
const MediaViewItem = memo(({ data, index }: MediaViewItemProps) => {
const explorerStore = useExplorerStore();
const selected = explorerStore.selectedRowIndex === index;
return (
<ViewItem
data={data}
index={index}
className={clsx(
'h-full w-full overflow-hidden border-2 border-transparent',
selected && 'border-accent'
)}
>
<div
className={clsx(
'group relative flex aspect-square items-center justify-center hover:bg-app-selectedItem',
selected && 'bg-app-selectedItem'
)}
>
<FileThumb
size={0}
data={data}
cover={explorerStore.mediaAspectSquare}
className="!rounded-none"
/>
<Button
variant="gray"
size="icon"
className="absolute right-2 top-2 hidden rounded-full shadow group-hover:block"
onClick={() => (getExplorerStore().quickViewObject = data)}
>
<ArrowsOutSimple />
</Button>
</div>
</ViewItem>
);
});
export default () => {
const explorerStore = useExplorerStore();
const dismissibleNoticeStore = useDismissibleNoticeStore();
const { data, scrollRef, onLoadMore, hasNextPage, isFetchingNextPage } =
useExplorerViewContext();
const gridPadding = 2;
const scrollBarWidth = 6;
const [width, setWidth] = useState(0);
const [lastSelectedIndex, setLastSelectedIndex] = useState(explorerStore.selectedRowIndex);
// Virtualizer count calculation
const amountOfColumns = explorerStore.mediaColumns;
const amountOfRows = Math.ceil(data.length / amountOfColumns);
// Virtualizer item size calculation
const itemSize = (width - gridPadding * 2 - scrollBarWidth) / amountOfColumns;
const rowVirtualizer = useVirtualizer({
count: amountOfRows,
getScrollElement: () => scrollRef.current,
estimateSize: () => (itemSize < 0 ? 0 : itemSize),
measureElement: () => itemSize,
paddingStart: gridPadding,
paddingEnd: gridPadding,
overscan: !dismissibleNoticeStore.mediaView ? 8 : 4
});
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: amountOfColumns,
getScrollElement: () => scrollRef.current,
estimateSize: () => (itemSize < 0 ? 0 : itemSize),
measureElement: () => itemSize,
paddingStart: gridPadding,
paddingEnd: gridPadding
});
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualColumns = columnVirtualizer.getVirtualItems();
useEffect(() => {
const lastRow = virtualRows[virtualRows.length - 1];
if (
(!lastRow || lastRow.index === amountOfRows - 1) &&
hasNextPage &&
!isFetchingNextPage
) {
onLoadMore?.();
}
}, [hasNextPage, onLoadMore, isFetchingNextPage, virtualRows, virtualColumns, data.length]);
function handleWindowResize() {
if (scrollRef.current) {
setWidth(scrollRef.current.offsetWidth);
}
}
// Resize view on initial render and reset selected item
useEffect(() => {
handleWindowResize();
getExplorerStore().selectedRowIndex = null;
return () => {
getExplorerStore().selectedRowIndex = null;
};
}, []);
// Resize view on window resize
useOnWindowResize(handleWindowResize);
// Resize view on item selection/deselection
useEffect(() => {
const { selectedRowIndex } = explorerStore;
setLastSelectedIndex(selectedRowIndex);
if (explorerStore.showInspector && typeof lastSelectedIndex !== typeof selectedRowIndex) {
handleWindowResize();
}
}, [explorerStore.selectedRowIndex]);
// Resize view on inspector toggle
useEffect(() => {
if (explorerStore.selectedRowIndex !== null) {
handleWindowResize();
}
}, [explorerStore.showInspector]);
// Measure virtual item on size change
useEffect(() => {
rowVirtualizer.measure();
columnVirtualizer.measure();
}, [rowVirtualizer, columnVirtualizer, itemSize]);
// Force recalculate range
// https://github.com/TanStack/virtual/issues/485
useMemo(() => {
// @ts-ignore
rowVirtualizer.calculateRange();
// @ts-ignore
columnVirtualizer.calculateRange();
}, [amountOfRows, amountOfColumns, rowVirtualizer, columnVirtualizer]);
// Select item with arrow up key
useKey('ArrowUp', (e) => {
e.preventDefault();
const { selectedRowIndex } = explorerStore;
if (selectedRowIndex === null) return;
getExplorerStore().selectedRowIndex = Math.max(selectedRowIndex - 1, 0);
});
// Select item with arrow down key
useKey('ArrowDown', (e) => {
e.preventDefault();
const { selectedRowIndex } = explorerStore;
if (selectedRowIndex === null) return;
getExplorerStore().selectedRowIndex = Math.min(selectedRowIndex + 1, data.length - 1);
});
if (!width) return null;
return (
<div
className="relative"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: `${columnVirtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualRows.map((virtualRow) => (
<React.Fragment key={virtualRow.index}>
{virtualColumns.map((virtualColumn, i) => {
const index = virtualRow.index * amountOfColumns + i;
const item = data[index];
if (!item) return null;
return (
<div
key={virtualColumn.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`
}}
>
<MediaViewItem key={item.item.id} data={item} index={index} />
</div>
);
})}
</React.Fragment>
))}
</div>
);
};

View File

@@ -12,7 +12,7 @@ import {
const Heading = tw.div`text-ink-dull text-xs font-semibold`;
const Subheading = tw.div`text-ink-dull mb-1 text-xs font-medium`;
const sortOptions: Record<FilePathSearchOrderingKeys, string> = {
export const sortOptions: Record<FilePathSearchOrderingKeys, string> = {
'none': 'None',
'name': 'Name',
'sizeInBytes': 'Size',
@@ -91,6 +91,7 @@ export default () => {
</Select>
</div>
</div>
<div className="flex w-full flex-col space-y-3 pt-2">
{explorerStore.layoutMode === 'media' ? (
<RadixCheckbox

View File

@@ -25,7 +25,7 @@ export function QuickPreview({ transformOrigin }: QuickPreviewProps) {
* explorerStore.quickViewObject is set to null the component will not close immediately.
* Instead, it will enter the beginning of the close transition and it must continue to display
* content for a few more seconds due to the ongoing animation. To handle this, the open state
* is decoupled from the store state, by assinging references to the required store properties
* is decoupled from the store state, by assigning references to the required store properties
* to render the component in the subscribe callback.
*/
useEffect(

View File

@@ -1,135 +0,0 @@
import clsx from 'clsx';
import { HTMLAttributes, PropsWithChildren, memo, useRef } from 'react';
import { createSearchParams, useMatch, useNavigate } from 'react-router-dom';
import { ExplorerItem, isPath, useLibraryContext, useLibraryMutation } from '@sd/client';
import { getExplorerStore, useExplorerConfigStore, useExplorerStore } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { TOP_BAR_HEIGHT } from '../TopBar';
import DismissibleNotice from './DismissibleNotice';
import ContextMenu from './File/ContextMenu';
import GridView from './GridView';
import ListView from './ListView';
import MediaView from './MediaView';
import { ViewContext } from './ViewContext';
import { getExplorerItemData, getItemFilePath } from './util';
interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement> {
data: ExplorerItem;
index: number;
contextMenuClassName?: string;
}
export const ViewItem = ({
data,
index,
children,
contextMenuClassName,
...props
}: ViewItemProps) => {
const explorerStore = useExplorerStore();
const { library } = useLibraryContext();
const navigate = useNavigate();
const { openFilePath } = usePlatform();
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
const filePath = getItemFilePath(data);
const explorerConfig = useExplorerConfigStore();
const onDoubleClick = () => {
if (isPath(data) && data.item.is_dir) {
navigate({
pathname: `/${library.uuid}/location/${getItemFilePath(data)?.location_id}`,
search: createSearchParams({
path: `${data.item.materialized_path}${data.item.name}/`
}).toString()
});
getExplorerStore().selectedRowIndex = null;
} else if (
openFilePath &&
filePath &&
explorerConfig.openOnDoubleClick &&
!explorerStore.isRenaming
) {
data.type === 'Path' &&
data.item.object_id &&
updateAccessTime.mutate(data.item.object_id);
openFilePath(library.uuid, filePath.id);
} else {
const { kind } = getExplorerItemData(data);
if (['Video', 'Image', 'Audio'].includes(kind)) {
getExplorerStore().quickViewObject = data;
}
}
};
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
getExplorerStore().selectedRowIndex = index;
};
return (
<ContextMenu data={data} className={contextMenuClassName}>
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={() => (getExplorerStore().selectedRowIndex = index)}
{...props}
>
{children}
</div>
</ContextMenu>
);
};
interface Props {
data: ExplorerItem[];
onLoadMore?(): void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
viewClassName?: string;
listViewHeadersClassName?: string;
scrollRef?: React.RefObject<HTMLDivElement>;
}
export default memo((props: Props) => {
const explorerStore = useExplorerStore();
const layoutMode = explorerStore.layoutMode;
const scrollRef = useRef<HTMLDivElement>(null);
// Hide notice on overview page
const isOverview = useMatch('/:libraryId/overview');
return (
<div
ref={props.scrollRef || scrollRef}
className={clsx(
'custom-scroll explorer-scroll h-screen',
layoutMode === 'grid' && 'overflow-x-hidden',
props.viewClassName
)}
style={{ paddingTop: TOP_BAR_HEIGHT }}
onClick={() => (getExplorerStore().selectedRowIndex = null)}
>
{!isOverview && <DismissibleNotice />}
<ViewContext.Provider
value={{
data: props.data,
scrollRef: props.scrollRef || scrollRef,
onLoadMore: props.onLoadMore,
hasNextPage: props.hasNextPage,
isFetchingNextPage: props.isFetchingNextPage
}}
>
{layoutMode === 'grid' && <GridView />}
{layoutMode === 'rows' && (
<ListView listViewHeadersClassName={props.listViewHeadersClassName} />
)}
{layoutMode === 'media' && <MediaView />}
</ViewContext.Provider>
</div>
);
});

View File

@@ -0,0 +1,110 @@
import clsx from 'clsx';
import { memo, useState } from 'react';
import { ExplorerItem, formatBytes } from '@sd/client';
import GridList from '~/components/GridList';
import { useExplorerStore } from '~/hooks/useExplorerStore';
import { ViewItem } from '.';
import RenameTextBox from '../File/RenameTextBox';
import FileThumb from '../File/Thumb';
import { useExplorerViewContext } from '../ViewContext';
import { getItemFilePath } from '../util';
interface GridViewItemProps {
data: ExplorerItem;
selected: boolean;
index: number;
}
const GridViewItem = memo(({ data, selected, index, ...props }: GridViewItemProps) => {
const filePathData = data ? getItemFilePath(data) : null;
const explorerStore = useExplorerStore();
return (
<ViewItem data={data} className="h-full w-full" {...props}>
<div className={clsx('mb-1 rounded-lg ', selected && 'bg-app-selectedItem')}>
<FileThumb data={data} size={explorerStore.gridItemSize} className="mx-auto" />
</div>
<div className="flex flex-col justify-center">
{filePathData && (
<RenameTextBox
filePathData={filePathData}
selected={selected}
className={clsx(
'text-center font-medium text-ink',
selected && 'bg-accent text-white dark:text-ink'
)}
style={{
maxHeight: explorerStore.gridItemSize / 3
}}
activeClassName="!text-ink"
/>
)}
{explorerStore.showBytesInGridView &&
(!explorerStore.isRenaming || (explorerStore.isRenaming && !selected)) && (
<span
className={clsx(
'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-tiny text-ink-dull '
)}
>
{formatBytes(Number(filePathData?.size_in_bytes || 0))}
</span>
)}
</div>
</ViewItem>
);
});
export default () => {
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
const itemDetailsHeight =
explorerStore.gridItemSize / 4 + (explorerStore.showBytesInGridView ? 20 : 0);
const itemHeight = explorerStore.gridItemSize + itemDetailsHeight;
return (
<GridList
scrollRef={explorerView.scrollRef}
count={explorerView.items?.length || 100}
size={{ width: explorerStore.gridItemSize, height: itemHeight }}
padding={12}
selectable={!!explorerView.items}
selected={explorerView.selected}
onSelectedChange={explorerView.onSelectedChange}
overscan={explorerView.overscan}
onLoadMore={explorerView.onLoadMore}
rowsBeforeLoadMore={explorerView.rowsBeforeLoadMore}
top={explorerView.top}
preventSelection={explorerStore.isRenaming || !explorerView.selectable}
preventContextMenuSelection={!explorerView.contextMenu}
>
{({ index, item: Item }) => {
if (!explorerView.items) {
return (
<Item className="p-px">
<div className="aspect-square animate-pulse rounded-md bg-app-box" />
<div className="mx-2 mt-3 h-2 animate-pulse rounded bg-app-box" />
{explorerStore.showBytesInGridView && (
<div className="mx-8 mt-2 h-1 animate-pulse rounded bg-app-box" />
)}
</Item>
);
}
const item = explorerView.items[index];
if (!item) return null;
const isSelected = Array.isArray(explorerView.selected)
? explorerView.selected.includes(item.item.id)
: explorerView.selected === item.item.id;
return (
<Item selected={isSelected} id={item.item.id}>
<GridViewItem data={item} selected={isSelected} index={index} />
</Item>
);
}}
</GridList>
);
};

View File

@@ -0,0 +1,724 @@
import {
ColumnDef,
ColumnSizingState,
Row,
flexRender,
getCoreRowModel,
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 { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
import { useBoundingclientrect, useKey } from 'rooks';
import useResizeObserver from 'use-resize-observer';
import { ExplorerItem, FilePath, ObjectKind, isObject, isPath } from '@sd/client';
import {
FilePathSearchOrderingKeys,
getExplorerStore,
useExplorerStore
} from '~/hooks/useExplorerStore';
import { useScrolled } from '~/hooks/useScrolled';
import { ViewItem } from '.';
import RenameTextBox from '../File/RenameTextBox';
import FileThumb from '../File/Thumb';
import { InfoPill } from '../Inspector';
import { useExplorerViewContext } from '../ViewContext';
import { getExplorerItemData, getItemFilePath } from '../util';
interface ListViewItemProps {
row: Row<ExplorerItem>;
columnSizing: ColumnSizingState;
paddingX: number;
}
const ListViewItem = memo((props: ListViewItemProps) => {
return (
<ViewItem data={props.row.original} className="w-full">
<div role="row" className="flex h-full items-center">
{props.row.getVisibleCells().map((cell, i, cells) => {
return (
<div
role="cell"
key={cell.id}
className={clsx(
'table-cell shrink-0 truncate px-4 text-xs text-ink-dull',
cell.column.columnDef.meta?.className
)}
style={{
width:
cell.column.getSize() -
(cells.length - 1 === i ? props.paddingX : 0)
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
</div>
</ViewItem>
);
});
export default () => {
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
const tableRef = useRef<HTMLDivElement>(null);
const tableHeaderRef = useRef<HTMLDivElement>(null);
const tableBodyRef = useRef<HTMLDivElement>(null);
const [sized, setSized] = useState(false);
const [locked, setLocked] = useState(true);
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
const [listOffset, setListOffset] = useState(0);
const [ranges, setRanges] = useState<[number, number][]>([]);
const top =
(explorerView.top || 0) +
(explorerView.scrollRef.current
? parseInt(getComputedStyle(explorerView.scrollRef.current).paddingTop)
: 0);
const { isScrolled } = useScrolled(
explorerView.scrollRef,
sized ? listOffset - top : undefined
);
const paddingX =
(typeof explorerView.padding === 'object'
? explorerView.padding.x
: explorerView.padding) || 16;
const paddingY =
(typeof explorerView.padding === 'object'
? explorerView.padding.y
: explorerView.padding) || 12;
const scrollBarWidth = 8;
const rowHeight = 45;
const { width: tableWidth = 0 } = useResizeObserver({ ref: tableRef });
const { width: headerWidth = 0 } = useResizeObserver({ ref: tableHeaderRef });
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>[]>(
() => [
{
id: 'name',
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 selectedId = Array.isArray(explorerView.selected)
? explorerView.selected[0]
: explorerView.selected;
const selected = selectedId === cell.row.original.item.id;
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}
disabled={
Array.isArray(explorerView.selected) &&
explorerView.selected.length > 1
}
activeClassName="absolute z-50 top-0.5 left-[58px] max-w-[calc(100%-60px)]"
/>
)}
</div>
);
}
},
{
id: 'kind',
header: 'Type',
enableSorting: false,
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>
);
}
},
{
id: 'sizeInBytes',
header: 'Size',
size: 100,
accessorFn: (file) => byteSize(Number(getItemFilePath(file)?.size_in_bytes || 0))
},
{
id: 'dateCreated',
header: 'Date Created',
accessorFn: (file) => dayjs(file.item.date_created).format('MMM Do YYYY')
},
{
header: 'Content ID',
enableSorting: false,
size: 180,
accessorFn: (file) => getExplorerItemData(file).casId
}
],
[explorerView.selected, explorerStore.isRenaming]
);
const table = useReactTable({
data: explorerView.items || [],
columns,
defaultColumn: { minSize: 100 },
state: { columnSizing },
onColumnSizingChange: setColumnSizing,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => String(row.item.id)
});
const tableLength = table.getTotalSize();
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: explorerView.items ? rows.length : 100,
getScrollElement: () => explorerView.scrollRef.current,
estimateSize: () => rowHeight,
paddingStart: paddingY + (isScrolled ? 35 : 0),
paddingEnd: paddingY,
scrollMargin: listOffset
});
const virtualRows = rowVirtualizer.getVirtualItems();
const rect = useBoundingclientrect(tableRef);
const selectedItems = useMemo(() => {
return Array.isArray(explorerView.selected)
? new Set(explorerView.selected)
: explorerView.selected;
}, [explorerView.selected]);
function handleResize() {
if (locked && Object.keys(columnSizing).length > 0) {
table.setColumnSizing((sizing) => {
const nameSize = sizing.name;
const nameColumnMinSize = table.getColumn('name')?.columnDef.minSize;
const newNameSize =
(nameSize || 0) + tableWidth - paddingX * 2 - scrollBarWidth - tableLength;
return {
...sizing,
...(nameSize !== undefined && nameColumnMinSize !== undefined
? {
name:
newNameSize >= nameColumnMinSize
? newNameSize
: nameColumnMinSize
}
: {})
};
});
} else {
if (Math.abs(tableWidth - tableLength) < 10) {
setLocked(true);
}
}
}
function handleRowClick(
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
row: Row<ExplorerItem>
) {
if (!explorerView.onSelectedChange) return;
const rowIndex = row.index;
const itemId = row.original.item.id;
if (e.shiftKey && Array.isArray(explorerView.selected)) {
const range = ranges[ranges.length - 1];
if (!range) return;
const [rangeStartId, rangeEndId] = range;
const rowsById = table.getCoreRowModel().rowsById;
const rangeStartRow = table.getRow(String(rangeStartId));
const rangeEndRow = table.getRow(String(rangeEndId));
const lastDirection = rangeStartRow.index < rangeEndRow.index ? 'down' : 'up';
const currentDirection = rangeStartRow.index < row.index ? 'down' : 'up';
const currentRowIndex = row.index;
const rangeEndItem = rowsById[rangeEndId];
if (!rangeEndItem) return;
const isCurrentHigher = currentRowIndex > rangeEndItem.index;
const indexes = isCurrentHigher
? Array.from(
{
length:
currentRowIndex -
rangeEndItem.index +
(rangeEndItem.index === 0 ? 1 : 0)
},
(_, i) => rangeStartRow.index + i + 1
)
: Array.from(
{ length: rangeEndItem.index - currentRowIndex },
(_, i) => rangeStartRow.index - (i + 1)
);
const updated = new Set(explorerView.selected);
if (isCurrentHigher) {
indexes.forEach((i) => {
updated.add(Number(rows[i]?.id));
});
} else {
indexes.forEach((i) => updated.add(Number(rows[i]?.id)));
}
if (lastDirection !== currentDirection) {
const sorted = Math.abs(rangeStartRow.index - rangeEndItem.index);
const indexes = Array.from({ length: sorted }, (_, i) =>
rangeStartRow.index < rangeEndItem.index
? rangeStartRow.index + (i + 1)
: rangeStartRow.index - (i + 1)
);
indexes.forEach(
(i) => i !== rangeStartRow.index && updated.delete(Number(rows[i]?.id))
);
}
explorerView.onSelectedChange?.([...updated]);
setRanges([...ranges.slice(0, ranges.length - 1), [rangeStartId, itemId]]);
} else if (e.metaKey && Array.isArray(explorerView.selected)) {
const updated = new Set(explorerView.selected);
if (updated.has(itemId)) {
updated.delete(itemId);
setRanges(ranges.filter((range) => range[0] !== rowIndex));
} else {
setRanges([...ranges.slice(0, ranges.length - 1), [itemId, itemId]]);
}
explorerView.onSelectedChange?.([...updated]);
} else if (e.button === 0) {
explorerView.onSelectedChange?.(explorerView.multiSelect ? [itemId] : itemId);
setRanges([[itemId, itemId]]);
}
}
function handleRowContextMenu(row: Row<ExplorerItem>) {
if (!explorerView.onSelectedChange || !explorerView.contextMenu) return;
const itemId = row.original.item.id;
if (
!selectedItems ||
(typeof selectedItems === 'object' && !selectedItems.has(itemId)) ||
(typeof selectedItems === 'number' && selectedItems !== itemId)
) {
explorerView.onSelectedChange(typeof selectedItems === 'object' ? [itemId] : itemId);
setRanges([[itemId, itemId]]);
}
}
function isSelected(id: number) {
return typeof selectedItems === 'object' ? !!selectedItems.has(id) : selectedItems === id;
}
useEffect(() => handleResize(), [tableWidth]);
// TODO: Improve this
useEffect(() => {
setListOffset(tableRef.current?.offsetTop || 0);
}, [rect]);
// Measure initial column widths
useEffect(() => {
if (tableRef.current) {
const columns = table.getAllColumns();
const sizings = columns.reduce(
(sizings, column) =>
column.id === 'name' ? sizings : { ...sizings, [column.id]: column.getSize() },
{} as ColumnSizingState
);
const scrollWidth = tableRef.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);
}
}, []);
// initialize ranges
useEffect(() => {
if (ranges.length === 0 && explorerView.selected) {
const id = Array.isArray(explorerView.selected)
? explorerView.selected[explorerView.selected.length - 1]
: explorerView.selected;
if (id) setRanges([[id, id]]);
}
}, []);
// Load more items
useEffect(() => {
if (explorerView.onLoadMore) {
const lastRow = virtualRows[virtualRows.length - 1];
if (lastRow) {
const rowsBeforeLoadMore = explorerView.rowsBeforeLoadMore || 1;
const loadMoreOnIndex =
rowsBeforeLoadMore > rows.length ||
lastRow.index > rows.length - rowsBeforeLoadMore
? rows.length - 1
: rows.length - rowsBeforeLoadMore;
if (lastRow.index === loadMoreOnIndex) explorerView.onLoadMore();
}
}
}, [virtualRows, rows.length, explorerView.rowsBeforeLoadMore, explorerView.onLoadMore]);
useKey(
['ArrowUp', 'ArrowDown'],
(e) => {
if (!explorerView.selectable) return;
e.preventDefault();
if (explorerView.onSelectedChange) {
const lastSelectedItemId = Array.isArray(explorerView.selected)
? explorerView.selected[explorerView.selected.length - 1]
: explorerView.selected;
if (lastSelectedItemId) {
const lastSelectedRow = table.getRow(lastSelectedItemId.toString());
if (lastSelectedRow) {
const nextRow =
rows[
e.key === 'ArrowUp'
? lastSelectedRow.index - 1
: lastSelectedRow.index + 1
];
if (nextRow) {
if (e.shiftKey && typeof selectedItems === 'object') {
const newSet = new Set(selectedItems);
if (
selectedItems?.has(Number(nextRow.id)) &&
selectedItems?.has(Number(lastSelectedRow.id))
) {
newSet.delete(Number(lastSelectedRow.id));
} else {
newSet.add(Number(nextRow.id));
}
explorerView.onSelectedChange([...newSet]);
setRanges([
...ranges.slice(0, ranges.length - 1),
// FIXME: Eslint is right here.
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
[ranges[ranges.length - 1]?.[0]!, Number(nextRow.id)]
]);
} else {
explorerView.onSelectedChange(
explorerView.multiSelect
? [Number(nextRow.id)]
: Number(nextRow.id)
);
setRanges([[Number(nextRow.id), Number(nextRow.id)]]);
}
if (explorerView.scrollRef.current) {
const tableBodyRect = tableBodyRef.current?.getBoundingClientRect();
const scrollRect =
explorerView.scrollRef.current.getBoundingClientRect();
const paddingTop = parseInt(
getComputedStyle(explorerView.scrollRef.current).paddingTop
);
const top =
(explorerView.top
? paddingTop + explorerView.top
: paddingTop) +
scrollRect.top +
(isScrolled ? 35 : 0);
const rowTop =
nextRow.index * rowHeight +
rowVirtualizer.options.paddingStart +
(tableBodyRect?.top || 0) +
scrollRect.top;
const rowBottom = rowTop + rowHeight;
if (rowTop < top) {
const scrollBy =
rowTop - top - (nextRow.index === 0 ? paddingY : 0);
explorerView.scrollRef.current.scrollBy({
top: scrollBy,
behavior: 'smooth'
});
} else if (rowBottom > scrollRect.bottom) {
const scrollBy =
rowBottom -
scrollRect.height +
(nextRow.index === rows.length - 1 ? paddingY : 0);
explorerView.scrollRef.current.scrollBy({
top: scrollBy,
behavior: 'smooth'
});
}
}
}
}
}
}
},
{ when: !explorerStore.isRenaming }
);
return (
<div className="flex w-full flex-col" ref={tableRef}>
{sized && (
<ScrollSync>
<>
<ScrollSyncPane>
<div
className={clsx(
'no-scrollbar table-header-group overflow-x-auto overscroll-x-none',
isScrolled && 'top-bar-blur fixed z-20 !bg-app/90'
)}
style={{
top: top,
width: isScrolled ? tableWidth : undefined
}}
>
<div className="flex">
{table.getHeaderGroups().map((headerGroup) => (
<div
ref={tableHeaderRef}
key={headerGroup.id}
className="flex grow border-b border-app-line/50"
>
{headerGroup.headers.map((header, i) => {
const size = header.column.getSize();
const isSorted =
explorerStore.orderBy === header.id;
return (
<div
key={header.id}
className="relative shrink-0 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={() => {
if (header.column.getCanSort()) {
if (isSorted) {
getExplorerStore().orderByDirection =
explorerStore.orderByDirection ===
'Asc'
? 'Desc'
: 'Asc';
} else {
getExplorerStore().orderBy =
header.id as FilePathSearchOrderingKeys;
}
}
}}
>
{header.isPlaceholder ? null : (
<div
className={clsx(
'flex items-center'
)}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
<div className="flex-1" />
{isSorted ? (
explorerStore.orderByDirection ===
'Asc' ? (
<CaretUp className="shrink-0 text-ink-faint" />
) : (
<CaretDown className="shrink-0 text-ink-faint" />
)
) : 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>
</ScrollSyncPane>
<ScrollSyncPane>
<div className="no-scrollbar overflow-x-auto overscroll-x-none">
<div
ref={tableBodyRef}
className="relative"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: headerWidth
}}
>
{virtualRows.map((virtualRow) => {
if (!explorerView.items) {
return (
<div
key={virtualRow.index}
className="absolute left-0 top-0 flex w-full py-px"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${
virtualRow.start -
rowVirtualizer.options.scrollMargin
}px)`,
paddingLeft: `${paddingX}px`,
paddingRight: `${paddingX}px`
}}
>
<div className="relative flex h-full w-full animate-pulse rounded-md bg-app-box" />
</div>
);
}
const row = rows[virtualRow.index];
if (!row) return null;
const selected = isSelected(row.original.item.id);
const previousRow = rows[virtualRow.index - 1];
const selectedPrior =
previousRow && isSelected(previousRow.original.item.id);
const nextRow = rows[virtualRow.index + 1];
const selectedNext =
nextRow && isSelected(nextRow.original.item.id);
return (
<div
key={row.id}
className={clsx(
'absolute left-0 top-0 flex w-full',
explorerStore.isRenaming && selected && 'z-10'
)}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${
virtualRow.start -
rowVirtualizer.options.scrollMargin
}px)`,
paddingLeft: `${paddingX}px`,
paddingRight: `${paddingX}px`
}}
>
<div
onMouseDown={(e) => handleRowClick(e, row)}
onContextMenu={() => handleRowContextMenu(row)}
className={clsx(
'relative flex h-full w-full rounded-md border',
virtualRow.index % 2 === 0 &&
'bg-[#00000006] dark:bg-[#00000030]',
selected
? 'border-accent !bg-accent/10'
: 'border-transparent',
selected &&
selectedPrior &&
'rounded-t-none border-t-0 border-t-transparent',
selected &&
selectedNext &&
'rounded-b-none border-b-0 border-b-transparent'
)}
>
{selectedPrior && (
<div className="absolute inset-x-3 top-0 h-px bg-accent/10" />
)}
<ListViewItem
row={row}
paddingX={paddingX}
columnSizing={columnSizing}
/>
</div>
</div>
);
})}
</div>
</div>
</ScrollSyncPane>
</>
</ScrollSync>
)}
</div>
);
};

View File

@@ -0,0 +1,97 @@
import clsx from 'clsx';
import { ArrowsOutSimple } from 'phosphor-react';
import { memo } from 'react';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import GridList from '~/components/GridList';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { ViewItem } from '.';
import FileThumb from '../File/Thumb';
import { useExplorerViewContext } from '../ViewContext';
interface MediaViewItemProps {
data: ExplorerItem;
index: number;
selected: boolean;
}
const MediaViewItem = memo(({ data, index, selected }: MediaViewItemProps) => {
const explorerStore = useExplorerStore();
return (
<ViewItem
data={data}
className={clsx(
'h-full w-full overflow-hidden border-2',
selected ? 'border-accent' : 'border-transparent'
)}
>
<div
className={clsx(
'group relative flex aspect-square items-center justify-center hover:bg-app-selectedItem',
selected && 'bg-app-selectedItem'
)}
>
<FileThumb
size={0}
data={data}
cover={explorerStore.mediaAspectSquare}
className="!rounded-none"
/>
<Button
variant="gray"
size="icon"
className="absolute right-2 top-2 hidden rounded-full shadow group-hover:block"
onClick={() => (getExplorerStore().quickViewObject = data)}
>
<ArrowsOutSimple />
</Button>
</div>
</ViewItem>
);
});
export default () => {
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
return (
<GridList
scrollRef={explorerView.scrollRef}
count={explorerView.items?.length || 100}
columns={explorerStore.mediaColumns}
selected={explorerView.selected}
onSelectedChange={explorerView.onSelectedChange}
overscan={explorerView.overscan}
onLoadMore={explorerView.onLoadMore}
rowsBeforeLoadMore={explorerView.rowsBeforeLoadMore}
top={explorerView.top}
preventSelection={!explorerView.selectable}
preventContextMenuSelection={!explorerView.contextMenu}
>
{({ index, item: Item }) => {
if (!explorerView.items) {
return (
<Item className="!p-px">
<div className="h-full animate-pulse bg-app-box" />
</Item>
);
}
const item = explorerView.items[index];
if (!item) return null;
const isSelected = Array.isArray(explorerView.selected)
? explorerView.selected.includes(item.item.id)
: explorerView.selected === item.item.id;
return (
<Item selectable selected={isSelected} index={index} id={item.item.id}>
<MediaViewItem data={item} index={index} selected={isSelected} />
</Item>
);
}}
</GridList>
);
};

View File

@@ -0,0 +1,161 @@
import clsx from 'clsx';
import {
Cards,
Columns,
FolderNotchOpen,
GridFour,
MonitorPlay,
Rows,
SquaresFour
} from 'phosphor-react';
import { HTMLAttributes, PropsWithChildren, ReactNode, memo, useState } from 'react';
import { createSearchParams, useNavigate } from 'react-router-dom';
import { useKey } from 'rooks';
import { ExplorerItem, isPath, useLibraryContext, useLibraryMutation } from '@sd/client';
import { ContextMenu } from '@sd/ui';
import { useExplorerConfigStore } from '~/hooks';
import { ExplorerLayoutMode, getExplorerStore, useExplorerStore } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import {
ExplorerViewContext,
ExplorerViewSelection,
ViewContext,
useExplorerViewContext
} from '../ViewContext';
import { getItemFilePath } from '../util';
import GridView from './GridView';
import ListView from './ListView';
import MediaView from './MediaView';
interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement> {
data: ExplorerItem;
}
export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
const explorerStore = useExplorerStore();
const explorerView = useExplorerViewContext();
const { library } = useLibraryContext();
const navigate = useNavigate();
const { openFilePath } = usePlatform();
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
const filePath = getItemFilePath(data);
const explorerConfig = useExplorerConfigStore();
const onDoubleClick = () => {
if (isPath(data) && data.item.is_dir) {
navigate({
pathname: `/${library.uuid}/location/${getItemFilePath(data)?.location_id}`,
search: createSearchParams({
path: `${data.item.materialized_path}${data.item.name}/`
}).toString()
});
} else if (
openFilePath &&
filePath &&
explorerConfig.openOnDoubleClick &&
!explorerStore.isRenaming
) {
if (data.type === 'Path' && data.item.object_id) {
updateAccessTime.mutate(data.item.object_id);
}
openFilePath(library.uuid, filePath.id);
}
};
return (
<ContextMenu.Root
trigger={
<div onDoubleClick={onDoubleClick} {...props}>
{children}
</div>
}
onOpenChange={explorerView.setIsContextMenuOpen}
disabled={!explorerView.contextMenu}
asChild={false}
>
{explorerView.contextMenu}
</ContextMenu.Root>
);
};
interface Props<T extends ExplorerViewSelection>
extends Omit<ExplorerViewContext<T>, 'multiSelect' | 'selectable'> {
layout: ExplorerLayoutMode;
className?: string;
emptyNotice?: ReactNode;
}
export default memo(({ layout, className, emptyNotice, ...contextProps }) => {
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
useKey('Space', (e) => {
e.preventDefault();
if (!getExplorerStore().quickViewObject) {
const selectedItem = contextProps.items?.find(
(item) =>
item.item.id ===
(Array.isArray(contextProps.selected)
? contextProps.selected[0]
: contextProps.selected)
);
if (selectedItem) {
getExplorerStore().quickViewObject = selectedItem;
}
}
});
const emptyNoticeIcon = () => {
let Icon;
switch (layout) {
case 'grid':
Icon = GridFour;
break;
case 'media':
Icon = MonitorPlay;
break;
case 'columns':
Icon = Columns;
break;
case 'rows':
Icon = Rows;
break;
}
return <Icon size={100} opacity={0.3} />;
};
return (
<div className={clsx('h-full w-full', className)}>
{contextProps.items === null ||
(contextProps.items && contextProps.items.length > 0) ? (
<ViewContext.Provider
value={
{
...contextProps,
multiSelect: Array.isArray(contextProps.selected),
selectable: !isContextMenuOpen,
setIsContextMenuOpen: setIsContextMenuOpen
} as ExplorerViewContext
}
>
{layout === 'grid' && <GridView />}
{layout === 'rows' && <ListView />}
{layout === 'media' && <MediaView />}
</ViewContext.Provider>
) : emptyNotice === null ? null : (
emptyNotice || (
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
{emptyNoticeIcon()}
<p className="mt-5 text-xs">This list is empty</p>
</div>
)
)}
</div>
);
}) as <T extends ExplorerViewSelection>(props: Props<T>) => JSX.Element;

View File

@@ -1,15 +1,25 @@
import { RefObject, createContext, useContext } from 'react';
import { ReactNode, RefObject, createContext, useContext } from 'react';
import { ExplorerItem } from '@sd/client';
interface Context {
data: ExplorerItem[];
export type ExplorerViewSelection = number | number[];
export interface ExplorerViewContext<T = ExplorerViewSelection> {
items: ExplorerItem[] | null;
scrollRef: RefObject<HTMLDivElement>;
isFetchingNextPage?: boolean;
onLoadMore?(): void;
hasNextPage?: boolean;
selected?: T;
onSelectedChange?: (selected: T) => void;
overscan?: number;
onLoadMore?: () => void;
rowsBeforeLoadMore?: number;
top?: number;
multiSelect?: boolean;
contextMenu?: ReactNode;
setIsContextMenuOpen?: (isOpen: boolean) => void;
selectable?: boolean;
padding?: number | { x?: number; y?: number };
}
export const ViewContext = createContext<Context | null>(null);
export const ViewContext = createContext<ExplorerViewContext | null>(null);
export const useExplorerViewContext = () => {
const ctx = useContext(ViewContext);

View File

@@ -1,39 +1,38 @@
import clsx from 'clsx';
import { ReactNode, useEffect, useMemo } from 'react';
import { useKey } from 'rooks';
import { FolderNotchOpen } from 'phosphor-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ExplorerItem, useLibrarySubscription } from '@sd/client';
import { getExplorerStore, useExplorerStore, useKeyDeleteFile } from '~/hooks';
import { useExplorerStore, useKeyDeleteFile } from '~/hooks';
import { TOP_BAR_HEIGHT } from '../TopBar';
import ExplorerContextMenu from './ContextMenu';
import DismissibleNotice from './DismissibleNotice';
import ContextMenu from './File/ContextMenu';
import { Inspector } from './Inspector';
import View from './View';
import { useExplorerSearchParams } from './util';
interface Props {
// TODO: not using data since context isn't actually used
// and it's not exactly compatible with search
// data?: ExplorerData;
items?: ExplorerItem[];
items: ExplorerItem[] | null;
onLoadMore?(): void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
viewClassName?: string;
children?: ReactNode;
inspectorClassName?: string;
explorerClassName?: string;
listViewHeadersClassName?: string;
scrollRef?: React.RefObject<HTMLDivElement>;
}
export default function Explorer(props: Props) {
const { selectedRowIndex, ...expStore } = useExplorerStore();
const INSPECTOR_WIDTH = 260;
const explorerStore = useExplorerStore();
const [{ path }] = useExplorerSearchParams();
const selectedItem = useMemo(() => {
if (selectedRowIndex === null) return null;
return props.items?.[selectedRowIndex] ?? null;
}, [selectedRowIndex, props.items]);
const scrollRef = useRef<HTMLDivElement>(null);
useKeyDeleteFile(selectedItem, expStore.locationId);
const [selectedItemId, setSelectedItemId] = useState<number>();
const selectedItem = useMemo(
() =>
selectedItemId
? props.items?.find((item) => item.item.id === selectedItemId)
: undefined,
[selectedItemId]
);
useLibrarySubscription(['jobs.newThumbnail'], {
onStarted: () => {
@@ -44,51 +43,54 @@ export default function Explorer(props: Props) {
},
onData: (cas_id) => {
console.log({ cas_id });
expStore.addNewThumbnail(cas_id);
explorerStore.addNewThumbnail(cas_id);
}
});
useEffect(() => {
getExplorerStore().selectedRowIndex = null;
}, [path]);
useKeyDeleteFile(selectedItem || null, explorerStore.locationId);
useKey('Space', (e) => {
e.preventDefault();
if (selectedItem) {
if (expStore.quickViewObject?.item.id === selectedItem.item.id) {
getExplorerStore().quickViewObject = null;
} else {
getExplorerStore().quickViewObject = selectedItem;
}
}
});
useEffect(() => setSelectedItemId(undefined), [path]);
return (
<div className="flex h-screen w-full flex-col bg-app">
<div className="flex flex-1">
<div className={clsx('flex-1 overflow-hidden', props.explorerClassName)}>
{props.children}
<ExplorerContextMenu>
{props.items && (
<View
scrollRef={props.scrollRef}
data={props.items}
onLoadMore={props.onLoadMore}
hasNextPage={props.hasNextPage}
listViewHeadersClassName={props.listViewHeadersClassName}
isFetchingNextPage={props.isFetchingNextPage}
viewClassName={props.viewClassName}
/>
)}
</ExplorerContextMenu>
</div>
{expStore.showInspector && (
<div className="w-[260px] shrink-0">
<Inspector className={props.inspectorClassName} data={selectedItem} />
<>
<ExplorerContextMenu>
<div className="flex-1 overflow-hidden">
<div
ref={scrollRef}
className="custom-scroll explorer-scroll relative h-screen overflow-x-hidden"
style={{
paddingTop: TOP_BAR_HEIGHT,
paddingRight: explorerStore.showInspector ? INSPECTOR_WIDTH : 0
}}
>
<DismissibleNotice />
<View
layout={explorerStore.layoutMode}
items={props.items}
scrollRef={scrollRef}
onLoadMore={props.onLoadMore}
rowsBeforeLoadMore={5}
selected={selectedItemId}
onSelectedChange={setSelectedItemId}
contextMenu={<ContextMenu data={selectedItem} />}
emptyNotice={
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
<FolderNotchOpen size={100} opacity={0.3} />
<p className="mt-5 text-xs">This folder is empty</p>
</div>
}
/>
</div>
)}
</div>
</div>
</div>
</ExplorerContextMenu>
{explorerStore.showInspector && (
<Inspector
data={selectedItem}
className="custom-scroll inspector-scroll absolute inset-y-0 right-0 pb-4 pl-1.5 pr-1"
style={{ paddingTop: TOP_BAR_HEIGHT + 16, width: INSPECTOR_WIDTH }}
/>
)}
</>
);
}

View File

@@ -9,7 +9,21 @@ export interface IJobGroup extends JobReport {
export function useGroupedJobs(jobs: JobReport[] = [], runningJobs: JobReport[] = []) {
return useMemo(() => {
return jobs.reduce((arr, job) => {
const childJobs = jobs.filter((j) => j.parent_id === job.id);
const childJobs = jobs
.filter((j) => j.parent_id === job.id || j.id === job.id)
// sort by started_at, a string date that is possibly null
.sort((a, b) => {
if (!a.started_at && !b.started_at) {
return 0;
}
if (!a.started_at) {
// a is null
return 1;
}
return a.started_at.localeCompare(b.started_at || '');
});
if (!jobs.some((j) => j.id === job.parent_id)) {
arr.push({

View File

@@ -12,7 +12,7 @@ export default () => {
<Dropdown.Button
variant="gray"
className={clsx(
`text-sidebar-ink w-full`,
`w-full text-sidebar-ink`,
// these classname overrides are messy
// but they work
`!border-sidebar-line/50 !bg-sidebar-box ring-offset-sidebar active:!border-sidebar-line active:!bg-sidebar-button ui-open:!border-sidebar-line ui-open:!bg-sidebar-button`,

View File

@@ -1,4 +1,4 @@
import { Laptop } from '@sd/assets/icons';
import { Laptop, Mobile, Server } from '@sd/assets/icons';
import clsx from 'clsx';
import { Link, NavLink } from 'react-router-dom';
import { arraysEqual, useBridgeQuery, useLibraryQuery, useOnlineLocations } from '@sd/client';
@@ -25,9 +25,21 @@ export const LibrarySection = () => {
</Link>
}
>
<SidebarLink className="group relative w-full" to={`/`} disabled key={'jeff'}>
{/* <SidebarLink className="group relative w-full" to={`/`} key={'jeff'}>
<img src={Laptop} className="mr-1 h-5 w-5" />
<span className="truncate">{node.data?.name}</span>
</SidebarLink> */}
<SidebarLink className="group relative w-full" to={`/`}>
<img src={Laptop} className="mr-1 h-5 w-5" />
<span className="truncate">Jamie's MBP</span>
</SidebarLink>
<SidebarLink className="group relative w-full" to={`/`}>
<img src={Mobile} className="mr-1 h-5 w-5" />
<span className="truncate">spacephone</span>
</SidebarLink>
<SidebarLink className="group relative w-full" to={`/`}>
<img src={Server} className="mr-1 h-5 w-5" />
<span className="truncate">titan</span>
</SidebarLink>
{/* {(locations.data?.length || 0) < 4 && (
<Button variant="dotted" className="mt-1 w-full">

View File

@@ -37,7 +37,7 @@ export default ({ options }: TopBarChildrenProps) => {
}, []);
return (
<div data-tauri-drag-region className="flex flex-row justify-end w-full">
<div data-tauri-drag-region className="flex w-full flex-row justify-end">
<div data-tauri-drag-region className={`flex gap-0`}>
{options?.map((group, groupIndex) => {
return group.map(

View File

@@ -1,6 +1,3 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { z } from 'zod';
import {
ExplorerItem,
useLibraryContext,
@@ -9,6 +6,9 @@ import {
useRspcLibraryContext
} from '@sd/client';
import { Folder } from '~/components/Folder';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { z } from 'zod';
import {
getExplorerStore,
useExplorerStore,
@@ -20,6 +20,7 @@ import Explorer from '../Explorer';
import { useExplorerOrder, useExplorerSearchParams } from '../Explorer/util';
import { TopBarPortal } from '../TopBar/Portal';
import TopBarOptions from '../TopBar/TopBarOptions';
import LocationOptions from './LocationOptions';
const PARAMS = z.object({
id: z.coerce.number()
@@ -50,20 +51,21 @@ export const Component = () => {
explorerStore.locationId = location_id;
}, [explorerStore, location_id, path]);
const { query, items } = useItems();
const file = explorerStore.selectedRowIndex !== null && items?.[explorerStore.selectedRowIndex];
useKeyDeleteFile(file as ExplorerItem, location_id);
const { items, loadMore } = useItems();
return (
<>
<TopBarPortal
left={
<>
<Folder size={22} className="ml-3 mr-2 mt-[-1px] inline-block" />
<span className="text-sm font-medium">
{path ? getLastSectionOfPath(path) : location.data?.name}
<div className='group flex flex-row items-center space-x-2'>
<span>
<Folder size={22} className="ml-3 mr-2 mt-[-1px] inline-block" />
<span className="text-sm font-medium">
{path ? getLastSectionOfPath(path) : location.data?.name}
</span>
</span>
</>
{location.data && <LocationOptions location={location.data} path={path || ""} />}
</div>
}
right={
<TopBarOptions
@@ -71,14 +73,8 @@ export const Component = () => {
/>
}
/>
<div className="relative flex w-full flex-col">
<Explorer
items={items}
onLoadMore={query.fetchNextPage}
hasNextPage={query.hasNextPage}
isFetchingNextPage={query.isFetchingNextPage}
/>
</div>
<Explorer items={items} onLoadMore={loadMore} />
</>
);
};
@@ -117,12 +113,19 @@ const useItems = () => {
cursor
}
]),
getNextPageParam: (lastPage) => lastPage.cursor ?? undefined
getNextPageParam: (lastPage) => lastPage.cursor ?? undefined,
keepPreviousData: true
});
const items = useMemo(() => query.data?.pages.flatMap((d) => d.items), [query.data]);
const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) || null, [query.data]);
return { query, items };
function loadMore() {
if (query.hasNextPage && !query.isFetchingNextPage) {
query.fetchNextPage();
}
}
return { query, items, loadMore };
};
function getLastSectionOfPath(path: string): string | undefined {

View File

@@ -0,0 +1,59 @@
import { Button, Popover, PopoverContainer, PopoverSection, Input, PopoverDivider, tw } from "@sd/ui";
import { Paperclip, Gear, FolderDotted, Archive, Image, Icon, IconContext, Copy } from "phosphor-react";
import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg';
import { Location, useLibraryMutation } from '@sd/client'
import TopBarButton from "../TopBar/TopBarButton";
const OptionButton = tw(TopBarButton)`w-full gap-1 !px-1.5 !py-1`
export default function LocationOptions({ location, path }: { location: Location, path: string }) {
const _scanLocation = useLibraryMutation('locations.fullRescan');
const scanLocation = () => _scanLocation.mutate(location.id);
const _regenThumbs = useLibraryMutation('jobs.generateThumbsForLocation');
const regenThumbs = () => _regenThumbs.mutate({ id: location.id, path });
const archiveLocation = () => alert("Not implemented");
let currentPath = path ? location.path + path : location.path;
currentPath = currentPath.endsWith("/") ? currentPath.substring(0, currentPath.length - 1) : currentPath;
return (
<div className='opacity-30 group-hover:opacity-70'>
<IconContext.Provider value={{ size: 20, className: "r-1 h-4 w-4 opacity-60" }}>
<Popover trigger={<Button className="!p-[5px]" variant="subtle">
<Ellipsis className="h-3 w-3" />
</Button>}>
<PopoverContainer>
<PopoverSection>
<Input autoFocus className='mb-2' value={currentPath} right={
<Button
size="icon"
variant="outline"
className='opacity-70'
>
<Copy className="!pointer-events-none h-4 w-4" />
</Button>
} />
<OptionButton><Gear />Configure Location</OptionButton>
</PopoverSection>
<PopoverDivider />
<PopoverSection>
<OptionButton onClick={scanLocation}><FolderDotted />Re-index</OptionButton>
<OptionButton onClick={regenThumbs}><Image />Regenerate Thumbs</OptionButton>
</PopoverSection>
<PopoverDivider />
<PopoverSection>
<OptionButton onClick={archiveLocation}><Archive />Archive</OptionButton>
</PopoverSection>
</PopoverContainer>
</Popover>
</IconContext.Provider>
</div>
)
}

View File

@@ -12,6 +12,7 @@ import {
useRspcLibraryContext
} from '@sd/client';
import { useExplorerStore } from '~/hooks';
import { useExplorerOrder } from '../Explorer/util';
export const IconForCategory: Partial<Record<Category, string>> = {
Recents: iconNames.Collection,
@@ -126,14 +127,21 @@ export function useItems(selectedCategory: Category) {
[objectsQuery.data]
);
const loadMore = () => {
const query = isObjectQuery ? objectsQuery : pathsQuery;
if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
};
return isObjectQuery
? {
items: objectsItems,
query: objectsQuery
query: objectsQuery,
loadMore
}
: {
items: pathsItems,
query: pathsQuery
query: pathsQuery,
loadMore
};
}

View File

@@ -1,9 +1,12 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import 'react-loading-skeleton/dist/skeleton.css';
import { useKey } from 'rooks';
import { Category } from '@sd/client';
import { z } from '@sd/ui/src/forms';
import { Category } from '~/../packages/client/src';
import { useExplorerTopBarOptions } from '~/hooks';
import Explorer from '../Explorer';
import { getExplorerStore, useExplorerStore, useExplorerTopBarOptions } from '~/hooks';
import ContextMenu from '../Explorer/File/ContextMenu';
import { Inspector } from '../Explorer/Inspector';
import View from '../Explorer/View';
import { SEARCH_PARAMS } from '../Explorer/util';
import { usePageLayout } from '../PageLayout';
import { TopBarPortal } from '../TopBar/Portal';
@@ -15,13 +18,23 @@ import { useItems } from './data';
export type SearchArgs = z.infer<typeof SEARCH_PARAMS>;
export const Component = () => {
const explorerStore = useExplorerStore();
const page = usePageLayout();
const { explorerViewOptions, explorerControlOptions, explorerToolOptions } =
useExplorerTopBarOptions();
const [selectedCategory, setSelectedCategory] = useState<Category>('Recents');
const { items, query } = useItems(selectedCategory);
const { items, query, loadMore } = useItems(selectedCategory);
const [selectedItemId, setSelectedItemId] = useState<number>();
const selectedItem = useMemo(
() => (selectedItemId ? items?.find((item) => item.item.id === selectedItemId) : undefined),
[selectedItemId]
);
return (
<>
@@ -32,20 +45,37 @@ export const Component = () => {
/>
}
/>
<Statistics />
<Explorer
inspectorClassName="!pt-0 !fixed !top-[50px] !right-[10px] !w-[260px]"
viewClassName="!pl-0 !pt-[0] !h-auto !overflow-visible"
explorerClassName="!overflow-visible" //required to keep categories sticky, remove with caution
listViewHeadersClassName="!top-[65px] z-30"
items={items}
onLoadMore={query.fetchNextPage}
hasNextPage={query.hasNextPage}
isFetchingNextPage={query.isFetchingNextPage}
scrollRef={page?.ref}
>
<div>
<Statistics />
<Categories selected={selectedCategory} onSelectedChanged={setSelectedCategory} />
</Explorer>
<div className="flex">
<View
layout={explorerStore.layoutMode}
items={query.isLoading ? null : items || []}
// TODO: Fix this type here.
scrollRef={page?.ref as any}
onLoadMore={loadMore}
rowsBeforeLoadMore={5}
selected={selectedItemId}
onSelectedChange={setSelectedItemId}
top={68}
className={explorerStore.layoutMode === 'rows' ? 'min-w-0' : undefined}
contextMenu={selectedItem && <ContextMenu data={selectedItem} />}
emptyNotice={null}
/>
{explorerStore.showInspector && (
<Inspector
data={selectedItem}
showThumbnail={explorerStore.layoutMode !== 'media'}
className="custom-scroll inspector-scroll sticky top-[68px] h-full w-[260px] shrink-0 bg-app pb-4 pl-1.5 pr-1"
/>
)}
</div>
</div>
</>
);
};

View File

@@ -233,15 +233,15 @@ function SystemTheme(props: ThemeProps) {
return (
<div className="h-full w-[150px]">
<div className="relative flex h-full">
<div className="relative h-full w-[50%] grow overflow-hidden rounded-bl-lg rounded-tl-lg bg-black">
<Theme className="rounded-br-none rounded-tr-none" {...themes[1]!} />
<div className="relative h-full w-[50%] grow overflow-hidden rounded-l-lg bg-black">
<Theme className="rounded-r-none" {...themes[1]!} />
</div>
<div
className={clsx(
'relative h-full w-[50%] grow overflow-hidden rounded-br-lg rounded-tr-lg'
'relative h-full w-[50%] grow overflow-hidden rounded-r-lg'
)}
>
<Theme className="rounded-bl-none rounded-tl-none" {...themes[0]!} />
<Theme className="rounded-l-none" {...themes[0]!} />
</div>
{props.isSelected && (
<CheckCircle

View File

@@ -189,7 +189,7 @@ const RulesForm = ({ onSubmitted }: Props) => {
control={form.control}
render={({ field }) => {
return (
<div className="flex flex-col w-full">
<div className="flex w-full flex-col">
<RuleInput
className={clsx(
'!h-[30px]',

View File

@@ -217,3 +217,10 @@ body {
width: 8px !important;
border-radius: 3px !important;
}
.selecto-selection {
@apply rounded;
border-color: hsla(var(--color-accent));
background-color: hsla(var(--color-accent), 0.2) !important;
z-index: 10 !important;
}

View File

@@ -0,0 +1,430 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import React, {
HTMLAttributes,
PropsWithChildren,
ReactNode,
cloneElement,
createContext,
useContext,
useRef
} from 'react';
import { RefObject, useEffect, useMemo, useState } from 'react';
import Selecto, { SelectoProps } from 'react-selecto';
import { useBoundingclientrect, useIntersectionObserverRef, useKey, useKeys } from 'rooks';
import useResizeObserver from 'use-resize-observer';
import { TOP_BAR_HEIGHT } from '~/app/$libraryId/TopBar';
type GridListSelection = number | number[];
interface GridListDefaults<T extends GridListSelection> {
count: number;
scrollRef: RefObject<HTMLElement>;
padding?: number | { x?: number; y?: number };
gap?: number | { x?: number; y?: number };
children: (props: {
index: number;
item: (props: GridListItemProps) => JSX.Element;
}) => JSX.Element | null;
selected?: T;
onSelectedChange?: (change: T) => void;
selectable?: boolean;
onSelect?: (index: number) => void;
onDeselect?: (index: number) => void;
overscan?: number;
top?: number;
onLoadMore?: () => void;
rowsBeforeLoadMore?: number;
preventSelection?: boolean;
preventContextMenuSelection?: boolean;
}
interface WrapProps<T extends GridListSelection> extends GridListDefaults<T> {
size: number | { width: number; height: number };
}
interface ResizeProps<T extends GridListSelection> extends GridListDefaults<T> {
columns: number;
}
type GridListProps<T extends GridListSelection> = WrapProps<T> | ResizeProps<T>;
export default <T extends GridListSelection>({ selectable = true, ...props }: GridListProps<T>) => {
const scrollBarWidth = 6;
const multiSelect = Array.isArray(props.selected);
const paddingX = (typeof props.padding === 'object' ? props.padding.x : props.padding) || 0;
const paddingY = (typeof props.padding === 'object' ? props.padding.y : props.padding) || 0;
const gapX = (typeof props.gap === 'object' ? props.gap.x : props.gap) || 0;
const gapY = (typeof props.gap === 'object' ? props.gap.y : props.gap) || 0;
const itemWidth =
'size' in props
? typeof props.size === 'object'
? props.size.width
: props.size
: undefined;
const itemHeight =
'size' in props
? typeof props.size === 'object'
? props.size.height
: props.size
: undefined;
const ref = useRef<HTMLDivElement>(null);
const { width = 0 } = useResizeObserver({ ref: ref });
const rect = useBoundingclientrect(ref);
const selecto = useRef<Selecto>(null);
const [scrollOptions, setScrollOptions] = React.useState<SelectoProps['scrollOptions']>();
const [listOffset, setListOffset] = useState(0);
const gridWidth = width - (paddingX || 0) * 2;
// Virtualizer count calculation
const amountOfColumns =
'columns' in props ? props.columns : itemWidth ? Math.floor(gridWidth / itemWidth) : 0;
const amountOfRows = amountOfColumns > 0 ? Math.ceil(props.count / amountOfColumns) : 0;
// Virtualizer item size calculation
const virtualItemWidth = amountOfColumns > 0 ? gridWidth / amountOfColumns : 0;
const virtualItemHeight = itemHeight || virtualItemWidth;
const rowVirtualizer = useVirtualizer({
count: amountOfRows,
getScrollElement: () => props.scrollRef.current,
estimateSize: () => virtualItemHeight,
measureElement: () => virtualItemHeight,
paddingStart: paddingY,
paddingEnd: paddingY,
overscan: props.overscan,
scrollMargin: listOffset
});
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: amountOfColumns,
getScrollElement: () => props.scrollRef.current,
estimateSize: () => virtualItemWidth,
measureElement: () => virtualItemWidth,
paddingStart: paddingX,
paddingEnd: paddingX
});
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualColumns = columnVirtualizer.getVirtualItems();
// Measure virtual item on size change
useEffect(() => {
rowVirtualizer.measure();
columnVirtualizer.measure();
}, [rowVirtualizer, columnVirtualizer, virtualItemWidth, virtualItemHeight]);
// Force recalculate range
// https://github.com/TanStack/virtual/issues/485
useMemo(() => {
// @ts-ignore
rowVirtualizer.calculateRange();
// @ts-ignore
columnVirtualizer.calculateRange();
}, [amountOfRows, amountOfColumns, rowVirtualizer, columnVirtualizer]);
// Set Selecto scroll options
useEffect(() => {
setScrollOptions({
container: props.scrollRef.current!,
getScrollPosition: () => {
// FIXME: Eslint is right here.
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
return [props.scrollRef.current?.scrollLeft!, props.scrollRef.current?.scrollTop!];
},
throttleTime: 30,
threshold: 0
});
}, []);
// Check Selecto scroll
useEffect(() => {
const handleScroll = () => {
selecto.current?.checkScroll();
};
props.scrollRef.current?.addEventListener('scroll', handleScroll);
return () => props.scrollRef.current?.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
setListOffset(ref.current?.offsetTop || 0);
}, [rect]);
// Handle key Selection
useKey(['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'], (e) => {
!props.preventSelection && e.preventDefault();
if (!selectable || !props.onSelectedChange || props.preventSelection) return;
const selectedItems = selecto.current?.getSelectedTargets() || [
...document.querySelectorAll<HTMLDivElement>(`[data-selected="true"]`)
];
const lastItem = selectedItems[selectedItems.length - 1];
if (lastItem) {
const currentIndex = Number(lastItem.getAttribute('data-selectable-index'));
let newIndex = currentIndex;
switch (e.key) {
case 'ArrowUp':
newIndex += -amountOfColumns;
break;
case 'ArrowDown':
newIndex += amountOfColumns;
break;
case 'ArrowRight':
newIndex += 1;
break;
case 'ArrowLeft':
newIndex += -1;
break;
}
const newSelectedItem = document.querySelector<HTMLDivElement>(
`[data-selectable-index="${newIndex}"]`
);
if (newSelectedItem) {
if (!multiSelect) {
const id = Number(newSelectedItem.getAttribute('data-selectable-id'));
props.onSelectedChange(id as T);
} else {
const addToGridListSelection = e.shiftKey;
selecto.current?.setSelectedTargets([
...(addToGridListSelection ? selectedItems : []),
newSelectedItem
]);
props.onSelectedChange(
[...(addToGridListSelection ? selectedItems : []), newSelectedItem].map(
(el) => Number(el.getAttribute('data-selectable-id'))
) as T
);
}
if (props.scrollRef.current) {
const direction = newIndex > currentIndex ? 'down' : 'up';
const itemRect = newSelectedItem.getBoundingClientRect();
const scrollRect = props.scrollRef.current.getBoundingClientRect();
const paddingTop = parseInt(
getComputedStyle(props.scrollRef.current).paddingTop
);
const top = props.top ? paddingTop + props.top : paddingTop;
switch (direction) {
case 'up': {
if (itemRect.top < top) {
props.scrollRef.current.scrollBy({
top: itemRect.top - top - paddingY - 1,
behavior: 'smooth'
});
}
break;
}
case 'down': {
if (itemRect.bottom > scrollRect.height) {
props.scrollRef.current.scrollBy({
top: itemRect.bottom - scrollRect.height + paddingY + 1,
behavior: 'smooth'
});
}
break;
}
}
}
}
}
});
useEffect(() => {
if (props.onLoadMore) {
const lastRow = virtualRows[virtualRows.length - 1];
if (lastRow) {
const rowsBeforeLoadMore = props.rowsBeforeLoadMore || 1;
const loadMoreOnIndex =
rowsBeforeLoadMore > amountOfRows ||
lastRow.index > amountOfRows - rowsBeforeLoadMore
? amountOfRows - 1
: amountOfRows - rowsBeforeLoadMore;
if (lastRow.index === loadMoreOnIndex) props.onLoadMore();
}
}
}, [virtualRows, amountOfRows, props.rowsBeforeLoadMore, props.onLoadMore]);
return (
<div
ref={ref}
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`
}}
>
{multiSelect && (
<Selecto
ref={selecto}
dragContainer={ref.current}
boundContainer={ref.current}
selectableTargets={['[data-selectable]']}
toggleContinueSelect={'shift'}
hitRate={0}
scrollOptions={scrollOptions}
onDragStart={(e) => {
if (e.inputEvent.target.nodeName === 'BUTTON') {
return false;
}
return true;
}}
onScroll={(e) => {
selecto.current;
props.scrollRef.current?.scrollBy(
e.direction[0]! * 10,
e.direction[1]! * 10
);
}}
onSelect={(e) => {
const set = new Set(props.selected as number[]);
e.removed.forEach((el) => {
set.delete(Number(el.getAttribute('data-selectable-id')));
});
e.added.forEach((el) => {
set.add(Number(el.getAttribute('data-selectable-id')));
});
props.onSelectedChange?.([...set] as T);
}}
/>
)}
{width !== 0 && (
<SelectoContext.Provider value={selecto}>
{virtualRows.map((virtualRow) => (
<React.Fragment key={virtualRow.index}>
{virtualColumns.map((virtualColumn) => {
const index =
virtualRow.index * amountOfColumns + virtualColumn.index;
const item = props.children({ index, item: GridListItem });
if (!item) return null;
return (
<div
key={virtualColumn.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${
virtualColumn.start
}px) translateY(${
virtualRow.start -
rowVirtualizer.options.scrollMargin
}px)`
}}
>
{cloneElement<GridListItemProps>(item, {
selectable: selectable && !!props.onSelectedChange,
index,
style: { width: itemWidth },
onClick: (id) => {
!multiSelect && props.onSelectedChange?.(id as T);
},
onContextMenu: (id) => {
!props.preventContextMenuSelection &&
!multiSelect &&
props.onSelectedChange?.(id as T);
}
})}
</div>
);
})}
</React.Fragment>
))}
</SelectoContext.Provider>
)}
</div>
);
};
const SelectoContext = createContext<React.RefObject<Selecto>>(undefined!);
const useSelecto = () => useContext(SelectoContext);
interface GridListItemProps
extends PropsWithChildren,
Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'onClick' | 'onContextMenu'> {
selectable?: boolean;
index?: number;
selected?: boolean;
id?: number;
onClick?: (id: number) => void;
onContextMenu?: (id: number) => void;
}
const GridListItem = ({ className, children, style, ...props }: GridListItemProps) => {
const ref = useRef<HTMLDivElement>(null);
const selecto = useSelecto();
useEffect(() => {
if (props.selectable && props.selected && selecto.current) {
const current = selecto.current.getSelectedTargets();
selecto.current?.setSelectedTargets([
...current.filter(
(el) => el.getAttribute('data-selectable-id') !== String(props.id)
),
ref.current!
]);
}
}, []);
const selectableProps = props.selectable
? {
'data-selectable': '',
'data-selectable-id': props.id || props.index,
'data-selectable-index': props.index,
'data-selected': props.selected
}
: {};
return (
<div
ref={ref}
{...selectableProps}
style={style}
className={clsx('mx-auto h-full', className)}
onClick={() => {
if (props.onClick && props.selectable) {
const id = props.id || props.index;
if (id) props.onClick(id);
}
}}
onContextMenu={() => {
if (props.onContextMenu && props.selectable) {
const id = props.id || props.index;
if (id) props.onContextMenu(id);
}
}}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,39 @@
import DragSelect from 'dragselect';
import React, { createContext, useContext, useEffect, useState } from 'react';
type ProviderProps = {
children: React.ReactNode;
settings?: ConstructorParameters<typeof DragSelect>[0];
};
const Context = createContext<DragSelect | undefined>(undefined);
function DragSelectProvider({ children, settings = {} }: ProviderProps) {
const [ds, setDS] = useState<DragSelect>();
useEffect(() => {
setDS((prevState) => {
if (prevState) return prevState;
return new DragSelect({});
});
return () => {
if (ds) {
console.log('stop');
ds.stop();
setDS(undefined);
}
};
}, [ds]);
useEffect(() => {
ds?.setSettings(settings);
}, [ds, settings]);
return <Context.Provider value={ds}>{children}</Context.Provider>;
}
function useDragSelect() {
return useContext(Context);
}
export { DragSelectProvider, useDragSelect };

View File

@@ -1,4 +1,5 @@
import { proxy, useSnapshot } from 'valtio';
import { proxyMap, proxySet } from 'valtio/utils';
import { z } from 'zod';
import { ExplorerItem, FilePathSearchOrdering, ObjectSearchOrdering } from '@sd/client';
import { resetStore } from '@sd/client';

View File

@@ -35,7 +35,8 @@
"@tanstack/react-query": "^4.12.0",
"@tanstack/react-query-devtools": "^4.22.0",
"@tanstack/react-table": "^8.8.5",
"@tanstack/react-virtual": "3.0.0-beta.18",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@types/react-scroll-sync": "^0.8.4",
"@vitejs/plugin-react": "^2.1.0",
"autoprefixer": "^10.4.12",
"byte-size": "^8.1.0",
@@ -43,6 +44,7 @@
"clsx": "^1.2.1",
"crypto-random-string": "^5.0.0",
"dayjs": "^1.11.5",
"dragselect": "^2.7.4",
"framer-motion": "^10.11.5",
"phosphor-react": "^1.4.1",
"react": "^18.2.0",
@@ -55,12 +57,15 @@
"react-qr-code": "^2.0.11",
"react-router": "6.9.0",
"react-router-dom": "6.9.0",
"react-scroll-sync": "^0.11.0",
"react-selecto": "^1.22.3",
"remix-params-helper": "^0.4.10",
"rooks": "^5.14.0",
"tailwindcss": "^3.3.2",
"ts-deepmerge": "^6.0.3",
"use-count-up": "^3.0.1",
"use-debounce": "^8.0.4",
"use-resize-observer": "^9.1.0",
"valtio": "^1.7.4"
},
"devDependencies": {

View File

@@ -6,6 +6,8 @@ import { PropsWithChildren, Suspense, createContext, useContext } from 'react';
interface ContextMenuProps extends RadixCM.MenuContentProps {
trigger: React.ReactNode;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
}
export const contextMenuClassNames = clsx(
@@ -20,10 +22,19 @@ export const contextMenuClassNames = clsx(
const context = createContext<boolean>(false);
export const useContextMenu = () => useContext(context);
const Root = ({ trigger, children, className, ...props }: ContextMenuProps) => {
const Root = ({
trigger,
children,
className,
onOpenChange,
disabled,
...props
}: ContextMenuProps) => {
return (
<RadixCM.Root>
<RadixCM.Trigger asChild>{trigger}</RadixCM.Trigger>
<RadixCM.Root onOpenChange={onOpenChange}>
<RadixCM.Trigger asChild onContextMenu={(e) => disabled && e.preventDefault()}>
{trigger}
</RadixCM.Trigger>
<RadixCM.Portal>
<RadixCM.Content className={clsx(contextMenuClassNames, className)} {...props}>
<context.Provider value={true}>{children}</context.Provider>

View File

@@ -108,7 +108,7 @@ const AnimatedDialogOverlay = animated(RDialog.Overlay);
export interface DialogProps<S extends FieldValues>
extends RDialog.DialogProps,
Omit<FormProps<S>, 'onSubmit'> {
Omit<FormProps<S>, 'onSubmit'> {
title?: string;
dialog: ReturnType<typeof useDialog>;
loading?: boolean;

View File

@@ -1,12 +1,17 @@
import * as Radix from '@radix-ui/react-popover';
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { tw } from '.';
interface Props extends Radix.PopoverContentProps {
trigger: React.ReactNode;
disabled?: boolean;
}
export const PopoverContainer = tw.div`flex flex-col p-1.5`;
export const PopoverSection = tw.div`flex flex-col`;
export const PopoverDivider = tw.div`my-2 border-b border-app-line`;
export const Popover = ({ trigger, children, disabled, className, ...props }: Props) => {
const triggerRef = useRef<HTMLButtonElement>(null);

View File

@@ -94,7 +94,7 @@
// menu
--color-menu: var(--light-hue), 5%, 100%;
--color-menu-line: var(--light-hue), 5%, 95%;
--color-menu-ink: var(--light-hue), 5%, 100%;
--color-menu-ink: var(--light-hue), 5%, 20%;
--color-menu-faint: var(--light-hue), 5%, 80%;
--color-menu-hover: var(--light-hue), 15%, 20%;
--color-menu-selected: var(--light-hue), 5%, 30%;

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.