mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-02-20 07:37:26 -05:00
[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:
@@ -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'
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
110
interface/app/$libraryId/Explorer/View/GridView.tsx
Normal file
110
interface/app/$libraryId/Explorer/View/GridView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
724
interface/app/$libraryId/Explorer/View/ListView.tsx
Normal file
724
interface/app/$libraryId/Explorer/View/ListView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
interface/app/$libraryId/Explorer/View/MediaView.tsx
Normal file
97
interface/app/$libraryId/Explorer/View/MediaView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
161
interface/app/$libraryId/Explorer/View/index.tsx
Normal file
161
interface/app/$libraryId/Explorer/View/index.tsx
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
59
interface/app/$libraryId/location/LocationOptions.tsx
Normal file
59
interface/app/$libraryId/location/LocationOptions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
430
interface/components/GridList.tsx
Normal file
430
interface/components/GridList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
interface/hooks/useDragSelect.tsx
Normal file
39
interface/hooks/useDragSelect.tsx
Normal 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 };
|
||||
@@ -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';
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user