mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-03 04:43:28 +00:00
[ENG-1353] explorer dnd (#1737)
* locations dnd * fix icon * reduce navigate timeout * fix types * another * fix drag overlay count * Update pnpm-lock.yaml * merge * ephemeral support and other improvements * merge * Tag dnd * merge * type * merge * remove offset * update dnd logic to not depend on drag source * handle allowed types if parent isn't available * saved searches dnd navigation * well * rendering * Update pnpm-lock.yaml * types * remove width * Temporary solution * merge * @dnd-kit/utilities * Update pnpm-lock.yaml * explorer path dnd * remove unused drag hook * fix dnd on LayeredFileIcon --------- Co-authored-by: Brendan Allan <brendonovich@outlook.com>
This commit is contained in:
parent
0db883d0ca
commit
caf4fc5cde
|
@ -26,4 +26,4 @@ export const ExplorerContextProvider = <TExplorer extends UseExplorer<any>>({
|
|||
children
|
||||
}: PropsWithChildren<{
|
||||
explorer: TExplorer;
|
||||
}>) => <ExplorerContext.Provider value={explorer as any}>{children}</ExplorerContext.Provider>;
|
||||
}>) => <ExplorerContext.Provider value={explorer}>{children}</ExplorerContext.Provider>;
|
||||
|
|
|
@ -15,7 +15,6 @@ import { getQuickPreviewStore } from '../QuickPreview/store';
|
|||
import { RevealInNativeExplorerBase } from '../RevealInNativeExplorer';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
import { useViewItemDoubleClick } from '../View/ViewItem';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { Conditional, ConditionalItem } from './ConditionalItem';
|
||||
import { useContextMenuContext } from './context';
|
||||
import OpenWith from './OpenWith';
|
||||
|
@ -101,7 +100,6 @@ export const Rename = new ConditionalItem({
|
|||
return {};
|
||||
},
|
||||
Component: () => {
|
||||
const explorerView = useExplorerViewContext();
|
||||
const keybind = useKeybindFactory();
|
||||
const os = useOperatingSystem(true);
|
||||
|
||||
|
@ -109,7 +107,7 @@ export const Rename = new ConditionalItem({
|
|||
<ContextMenu.Item
|
||||
label="Rename"
|
||||
keybind={keybind([], [os === 'windows' ? 'F2' : 'Enter'])}
|
||||
onClick={() => explorerView.setIsRenaming(true)}
|
||||
onClick={() => (getExplorerStore().isRenaming = true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
98
interface/app/$libraryId/Explorer/DragOverlay.tsx
Normal file
98
interface/app/$libraryId/Explorer/DragOverlay.tsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
import type { ClientRect, Modifier } from '@dnd-kit/core';
|
||||
import { DragOverlay as DragOverlayPrimitive } from '@dnd-kit/core';
|
||||
import { getEventCoordinates } from '@dnd-kit/utilities';
|
||||
import clsx from 'clsx';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
import { useIsDark } from '~/hooks';
|
||||
|
||||
import { FileThumb } from './FilePath/Thumb';
|
||||
import { useExplorerStore } from './store';
|
||||
import { RenamableItemText } from './View/RenamableItemText';
|
||||
|
||||
const useSnapToCursorModifier = () => {
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const initialRect = useRef<ClientRect | null>(null);
|
||||
|
||||
const modifier: Modifier = ({ activatorEvent, activeNodeRect, transform }) => {
|
||||
if (!activeNodeRect || !activatorEvent) return transform;
|
||||
|
||||
const activatorCoordinates = getEventCoordinates(activatorEvent);
|
||||
if (!activatorCoordinates) return transform;
|
||||
|
||||
const rect = initialRect.current ?? activeNodeRect;
|
||||
|
||||
if (!initialRect.current) initialRect.current = activeNodeRect;
|
||||
|
||||
// Default offset so during drag the cursor doesn't overlap the overlay
|
||||
// which can cause issues with mouse events on other elements
|
||||
const offset = 12;
|
||||
|
||||
const offsetX = activatorCoordinates.x - rect.left;
|
||||
const offsetY = activatorCoordinates.y - rect.top;
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: transform.x + offsetX + offset,
|
||||
y: transform.y + offsetY + offset
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!explorerStore.drag) initialRect.current = null;
|
||||
}, [explorerStore.drag]);
|
||||
|
||||
return modifier;
|
||||
};
|
||||
|
||||
export const DragOverlay = memo(() => {
|
||||
const isDark = useIsDark();
|
||||
|
||||
const modifier = useSnapToCursorModifier();
|
||||
|
||||
const { drag } = useExplorerStore();
|
||||
|
||||
return (
|
||||
<DragOverlayPrimitive
|
||||
dropAnimation={null}
|
||||
modifiers={[modifier]}
|
||||
className="!h-auto !w-full max-w-md"
|
||||
>
|
||||
{!drag || drag.type === 'touched' ? null : (
|
||||
<div className="space-y-[2px] pl-0.5 pt-0.5 duration-300 animate-in fade-in">
|
||||
{drag.items.length > 1 && (
|
||||
<div className="absolute right-full top-1.5 mr-2 flex h-6 min-w-[24px] items-center justify-center rounded-full bg-accent px-1 text-sm text-white">
|
||||
{drag.items.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(drag.items.slice(0, 8) as ExplorerItem[]).map((item, i, items) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
'flex items-center gap-2',
|
||||
drag.items.length > 7 && [
|
||||
i + 1 === items.length && 'opacity-10',
|
||||
i + 2 === items.length && 'opacity-50',
|
||||
i + 3 === items.length && 'opacity-90'
|
||||
]
|
||||
)}
|
||||
>
|
||||
<FileThumb
|
||||
data={item}
|
||||
size={32}
|
||||
frame
|
||||
frameClassName={clsx(
|
||||
'!border-[1px] shadow-md',
|
||||
isDark ? 'shadow-app-shade/50' : 'shadow-app-shade/25'
|
||||
)}
|
||||
/>
|
||||
<RenamableItemText item={item} highlight={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlayPrimitive>
|
||||
);
|
||||
});
|
28
interface/app/$libraryId/Explorer/ExplorerDraggable.tsx
Normal file
28
interface/app/$libraryId/Explorer/ExplorerDraggable.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import { useExplorerDraggable, UseExplorerDraggableProps } from './useExplorerDraggable';
|
||||
|
||||
/**
|
||||
* Wrapper for explorer draggable items until dnd-kit solvers their re-rendering issues
|
||||
* https://github.com/clauderic/dnd-kit/issues/1194#issuecomment-1696704815
|
||||
*/
|
||||
export const ExplorerDraggable = ({
|
||||
draggable,
|
||||
...props
|
||||
}: Omit<HTMLAttributes<HTMLDivElement>, 'draggable'> & {
|
||||
draggable: UseExplorerDraggableProps;
|
||||
}) => {
|
||||
const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable(draggable);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={setDraggableRef}
|
||||
style={{ ...props.style, ...style }}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
36
interface/app/$libraryId/Explorer/ExplorerDroppable.tsx
Normal file
36
interface/app/$libraryId/Explorer/ExplorerDroppable.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import clsx from 'clsx';
|
||||
import { createContext, HTMLAttributes, useContext, useMemo } from 'react';
|
||||
|
||||
import { useExplorerDroppable, UseExplorerDroppableProps } from './useExplorerDroppable';
|
||||
|
||||
const ExplorerDroppableContext = createContext<{ isDroppable: boolean } | null>(null);
|
||||
|
||||
export const useExplorerDroppableContext = () => {
|
||||
const ctx = useContext(ExplorerDroppableContext);
|
||||
|
||||
if (ctx === null) throw new Error('ExplorerDroppableContext.Provider not found!');
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for explorer droppable items until dnd-kit solvers their re-rendering issues
|
||||
* https://github.com/clauderic/dnd-kit/issues/1194#issuecomment-1696704815
|
||||
*/
|
||||
export const ExplorerDroppable = ({
|
||||
droppable,
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { droppable: UseExplorerDroppableProps }) => {
|
||||
const { isDroppable, className, setDroppableRef } = useExplorerDroppable(droppable);
|
||||
|
||||
const context = useMemo(() => ({ isDroppable }), [isDroppable]);
|
||||
|
||||
return (
|
||||
<ExplorerDroppableContext.Provider value={context}>
|
||||
<div {...props} ref={setDroppableRef} className={clsx(props.className, className)}>
|
||||
{children}
|
||||
</div>
|
||||
</ExplorerDroppableContext.Provider>
|
||||
);
|
||||
};
|
164
interface/app/$libraryId/Explorer/ExplorerPath.tsx
Normal file
164
interface/app/$libraryId/Explorer/ExplorerPath.tsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
import { CaretRight } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import { getExplorerItemData, getIndexedItemFilePath, useLibraryQuery } from '@sd/client';
|
||||
import { Icon } from '~/components';
|
||||
import { useIsDark, useOperatingSystem } from '~/hooks';
|
||||
|
||||
import { useExplorerContext } from './Context';
|
||||
import { FileThumb } from './FilePath/Thumb';
|
||||
import { useExplorerDroppable } from './useExplorerDroppable';
|
||||
import { useExplorerSearchParams } from './util';
|
||||
|
||||
export const PATH_BAR_HEIGHT = 32;
|
||||
|
||||
export const ExplorerPath = memo(() => {
|
||||
const os = useOperatingSystem();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [{ path: searchPath }] = useExplorerSearchParams();
|
||||
const { parent: explorerParent, selectedItems } = useExplorerContext();
|
||||
|
||||
const pathSlash = os === 'windows' ? '\\' : '/';
|
||||
|
||||
const location = explorerParent?.type === 'Location' ? explorerParent.location : undefined;
|
||||
|
||||
const selectedItem = useMemo(
|
||||
() => (selectedItems.size === 1 ? [...selectedItems][0] : undefined),
|
||||
[selectedItems]
|
||||
);
|
||||
|
||||
const indexedFilePath = selectedItem && getIndexedItemFilePath(selectedItem);
|
||||
|
||||
const queryPath = !!indexedFilePath && (!searchPath || !location);
|
||||
|
||||
const { data: filePathname } = useLibraryQuery(['files.getPath', indexedFilePath?.id ?? -1], {
|
||||
enabled: queryPath
|
||||
});
|
||||
|
||||
const paths = useMemo(() => {
|
||||
// Remove file name from the path
|
||||
const _filePathname = filePathname?.slice(0, filePathname.lastIndexOf(pathSlash));
|
||||
|
||||
const pathname = _filePathname ?? [location?.path, searchPath].filter(Boolean).join('');
|
||||
|
||||
const paths = [...(pathname.match(new RegExp(`[^${pathSlash}]+`, 'g')) ?? [])];
|
||||
|
||||
let locationPath = location?.path;
|
||||
|
||||
if (!locationPath && indexedFilePath?.materialized_path) {
|
||||
if (indexedFilePath.materialized_path === '/') locationPath = pathname;
|
||||
else {
|
||||
// Remove last slash from materialized_path
|
||||
const materializedPath = indexedFilePath.materialized_path.slice(0, -1);
|
||||
|
||||
// Extract location path from pathname
|
||||
locationPath = pathname.slice(0, pathname.indexOf(materializedPath));
|
||||
}
|
||||
}
|
||||
|
||||
const locationIndex = (locationPath ?? '').split(pathSlash).filter(Boolean).length - 1;
|
||||
|
||||
return paths.map((path, i) => {
|
||||
const isLocation = locationIndex !== -1 && i >= locationIndex;
|
||||
|
||||
const _paths = [
|
||||
...paths.slice(!isLocation ? 0 : locationIndex + 1, i),
|
||||
i === locationIndex ? '' : path
|
||||
];
|
||||
|
||||
let pathname = `${pathSlash}${_paths.join(pathSlash)}`;
|
||||
|
||||
// Add slash to the end of the pathname if it's a location
|
||||
if (isLocation && i > locationIndex) pathname += pathSlash;
|
||||
|
||||
return {
|
||||
name: path,
|
||||
pathname,
|
||||
locationId: isLocation ? indexedFilePath?.location_id ?? location?.id : undefined
|
||||
};
|
||||
});
|
||||
}, [location, indexedFilePath, filePathname, pathSlash, searchPath]);
|
||||
|
||||
const handleOnClick = ({ pathname, locationId }: (typeof paths)[number]) => {
|
||||
if (locationId === undefined) {
|
||||
// TODO: Handle ephemeral volumes
|
||||
navigate({
|
||||
pathname: '../ephemeral/0-0',
|
||||
search: `${createSearchParams({ path: pathname })}`
|
||||
});
|
||||
} else {
|
||||
navigate({
|
||||
pathname: `../location/${locationId}`,
|
||||
search: pathname === '/' ? undefined : `${createSearchParams({ path: pathname })}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group absolute inset-x-0 bottom-0 z-50 flex items-center border-t border-t-app-line bg-app/90 px-3.5 text-[11px] text-ink-dull backdrop-blur-lg"
|
||||
style={{ height: PATH_BAR_HEIGHT }}
|
||||
>
|
||||
{paths.map((path) => (
|
||||
<Path
|
||||
key={path.pathname}
|
||||
path={path}
|
||||
onClick={() => handleOnClick(path)}
|
||||
disabled={path.pathname === (searchPath ?? (location && '/'))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selectedItem && (!queryPath || filePathname) && (
|
||||
<div className="ml-1 flex items-center gap-1">
|
||||
<FileThumb data={selectedItem} size={16} frame frameClassName="!border" />
|
||||
<span className="max-w-xs truncate">
|
||||
{getExplorerItemData(selectedItem).fullName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface PathProps {
|
||||
path: { name: string; pathname: string; locationId?: number };
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const Path = ({ path, onClick, disabled }: PathProps) => {
|
||||
const isDark = useIsDark();
|
||||
|
||||
const { setDroppableRef, className, isDroppable } = useExplorerDroppable({
|
||||
data: {
|
||||
type: 'location',
|
||||
path: path.pathname,
|
||||
data: path.locationId ? { id: path.locationId, path: path.pathname } : undefined
|
||||
},
|
||||
allow: ['Path', 'NonIndexedPath', 'Object'],
|
||||
navigateTo: onClick,
|
||||
disabled
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setDroppableRef}
|
||||
className={clsx(
|
||||
'group flex items-center gap-1 rounded px-1 py-0.5',
|
||||
isDroppable && [isDark ? 'bg-app-lightBox' : 'bg-app-darkerBox'],
|
||||
!disabled && [isDark ? 'hover:bg-app-lightBox' : 'hover:bg-app-darkerBox'],
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Icon name="Folder" size={16} alt="Folder" />
|
||||
<span className="max-w-xs truncate text-ink-dull">{path.name}</span>
|
||||
<CaretRight weight="bold" className="text-ink-dull group-last:hidden" size={10} />
|
||||
</button>
|
||||
);
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import { getLayeredIcon } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { type ImgHTMLAttributes } from 'react';
|
||||
import { forwardRef, type ImgHTMLAttributes } from 'react';
|
||||
import { type ObjectKindKey } from '@sd/client';
|
||||
|
||||
interface LayeredFileIconProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
|
@ -16,28 +16,32 @@ const positionConfig: Record<string, string> = {
|
|||
Config: 'flex h-full w-full items-center justify-center'
|
||||
};
|
||||
|
||||
const LayeredFileIcon = ({ kind, extension, ...props }: LayeredFileIconProps) => {
|
||||
const iconImg = <img {...props} />;
|
||||
const LayeredFileIcon = forwardRef<HTMLImageElement, LayeredFileIconProps>(
|
||||
({ kind, extension, ...props }, ref) => {
|
||||
const iconImg = <img ref={ref} {...props} />;
|
||||
|
||||
if (SUPPORTED_ICONS.includes(kind) === false) {
|
||||
return iconImg;
|
||||
}
|
||||
if (SUPPORTED_ICONS.includes(kind) === false) {
|
||||
return iconImg;
|
||||
}
|
||||
|
||||
const IconComponent = extension ? getLayeredIcon(kind, extension) : null;
|
||||
const IconComponent = extension ? getLayeredIcon(kind, extension) : null;
|
||||
|
||||
const positionClass =
|
||||
positionConfig[kind] || 'flex h-full w-full items-end justify-end pb-4 pr-2';
|
||||
const positionClass =
|
||||
positionConfig[kind] || 'flex h-full w-full items-end justify-end pb-4 pr-2';
|
||||
|
||||
return IconComponent == null ? (
|
||||
iconImg
|
||||
) : (
|
||||
<div className="relative">
|
||||
{iconImg}
|
||||
<div className={clsx('absolute bottom-0 right-0', positionClass)}>
|
||||
<IconComponent viewBox="0 0 16 16" height="40%" width="40%" />
|
||||
return IconComponent == null ? (
|
||||
iconImg
|
||||
) : (
|
||||
<div className="relative">
|
||||
{iconImg}
|
||||
<div
|
||||
className={clsx('pointer-events-none absolute bottom-0 right-0', positionClass)}
|
||||
>
|
||||
<IconComponent viewBox="0 0 16 16" height="40%" width="40%" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default LayeredFileIcon;
|
||||
|
|
|
@ -182,7 +182,7 @@ interface VideoProps extends VideoHTMLAttributes<HTMLVideoElement> {
|
|||
blackBarsSize?: number;
|
||||
}
|
||||
|
||||
const Video = memo(({ paused, blackBars, blackBarsSize, className, ...props }: VideoProps) => {
|
||||
const Video = ({ paused, blackBars, blackBarsSize, className, ...props }: VideoProps) => {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const size = useSize(ref);
|
||||
|
@ -220,4 +220,4 @@ const Video = memo(({ paused, blackBars, blackBarsSize, className, ...props }: V
|
|||
<p>Video preview is not supported.</p>
|
||||
</video>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,31 +1,41 @@
|
|||
import clsx from 'clsx';
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import TruncateMarkup from 'react-truncate-markup';
|
||||
import { Tooltip } from '@sd/ui';
|
||||
import { useOperatingSystem, useShortcut } from '~/hooks';
|
||||
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export interface RenameTextBoxProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
onRename: (newName: string) => void;
|
||||
disabled?: boolean;
|
||||
lines?: number;
|
||||
// Temporary solution for TruncatedText in list view
|
||||
idleClassName?: string;
|
||||
}
|
||||
|
||||
export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
|
||||
({ name, onRename, disabled, className, lines, ...props }, _ref) => {
|
||||
const explorerView = useExplorerViewContext();
|
||||
export const RenameTextBox = forwardRef<HTMLDivElement, RenameTextBoxProps>(
|
||||
({ name, onRename, disabled, className, idleClassName, lines, ...props }, _ref) => {
|
||||
const os = useOperatingSystem();
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const [allowRename, setAllowRename] = useState(false);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(_ref, () => ref.current);
|
||||
|
||||
const renamable = useRef<boolean>(false);
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(_ref, () => ref.current);
|
||||
const [allowRename, setAllowRename] = useState(false);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
// Highlight file name up to extension or
|
||||
// fully if it's a directory, hidden file or has no extension
|
||||
|
@ -99,12 +109,6 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
|
|||
}
|
||||
};
|
||||
|
||||
const ellipsis = useCallback(() => {
|
||||
const extension = name.lastIndexOf('.');
|
||||
if (extension !== -1) return `...${name.slice(-(name.length - extension + 2))}`;
|
||||
return `...${name.slice(-8)}`;
|
||||
}, [name]);
|
||||
|
||||
useShortcut('renameObject', (e) => {
|
||||
e.preventDefault();
|
||||
if (allowRename) blur();
|
||||
|
@ -128,10 +132,10 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
|
|||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
if (explorerView.isRenaming && !allowRename) setAllowRename(true);
|
||||
else explorerView.setIsRenaming(allowRename);
|
||||
if (explorerStore.isRenaming && !allowRename) setAllowRename(true);
|
||||
else getExplorerStore().isRenaming = allowRename;
|
||||
} else resetState();
|
||||
}, [explorerView.isRenaming, disabled, allowRename, explorerView]);
|
||||
}, [explorerStore.isRenaming, disabled, allowRename]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
|
@ -146,7 +150,11 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
|
|||
<Tooltip
|
||||
labelClassName="break-all"
|
||||
tooltipClassName="!max-w-[250px]"
|
||||
label={!isTruncated || allowRename ? null : name}
|
||||
label={
|
||||
!isTruncated || allowRename || explorerStore.drag?.type === 'dragging'
|
||||
? null
|
||||
: name
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<div
|
||||
|
@ -158,6 +166,7 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
|
|||
className={clsx(
|
||||
'cursor-default overflow-hidden rounded-md px-1.5 py-px text-xs text-ink outline-none',
|
||||
allowRename && 'whitespace-normal bg-app !text-ink ring-2 ring-accent-deep',
|
||||
!allowRename && idleClassName,
|
||||
className
|
||||
)}
|
||||
onDoubleClick={(e) => {
|
||||
|
@ -176,7 +185,7 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
|
|||
onBlur={() => {
|
||||
handleRename();
|
||||
resetState();
|
||||
explorerView.setIsRenaming(false);
|
||||
getExplorerStore().isRenaming = false;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
|
@ -184,16 +193,30 @@ export const RenameTextBox = forwardRef<HTMLDivElement, Props>(
|
|||
{allowRename ? (
|
||||
name
|
||||
) : (
|
||||
<TruncateMarkup
|
||||
lines={lines}
|
||||
ellipsis={ellipsis}
|
||||
onTruncate={setIsTruncated}
|
||||
>
|
||||
<div>{name}</div>
|
||||
</TruncateMarkup>
|
||||
<TruncatedText text={name} lines={lines} onTruncate={setIsTruncated} />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface TruncatedTextProps {
|
||||
text: string;
|
||||
lines?: number;
|
||||
onTruncate: (wasTruncated: boolean) => void;
|
||||
}
|
||||
|
||||
const TruncatedText = memo(({ text, lines, onTruncate }: TruncatedTextProps) => {
|
||||
const ellipsis = useCallback(() => {
|
||||
const extension = text.lastIndexOf('.');
|
||||
if (extension !== -1) return `...${text.slice(-(text.length - extension + 2))}`;
|
||||
return `...${text.slice(-8)}`;
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<TruncateMarkup lines={lines} ellipsis={ellipsis} onTruncate={onTruncate}>
|
||||
<div>{text}</div>
|
||||
</TruncateMarkup>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
import { getIcon, getIconByName } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { memo, SyntheticEvent, useMemo, useRef, useState, type ImgHTMLAttributes } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
SyntheticEvent,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { getItemFilePath, useLibraryContext, type ExplorerItem } from '@sd/client';
|
||||
import { useIsDark } from '~/hooks';
|
||||
import { pdfViewerEnabled } from '~/util/pdfViewer';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { useExplorerItemData } from '../util';
|
||||
import { Image, ImageProps } from './Image';
|
||||
import LayeredFileIcon from './LayeredFileIcon';
|
||||
|
@ -32,18 +39,18 @@ export interface ThumbProps {
|
|||
frameClassName?: string;
|
||||
childClassName?: string | ((type: ThumbType) => string | undefined);
|
||||
isSidebarPreview?: boolean;
|
||||
childProps?: HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
type ThumbType = { variant: 'original' } | { variant: 'thumbnail' } | { variant: 'icon' };
|
||||
|
||||
export const FileThumb = memo((props: ThumbProps) => {
|
||||
export const FileThumb = forwardRef<HTMLImageElement, ThumbProps>((props, ref) => {
|
||||
const isDark = useIsDark();
|
||||
const platform = usePlatform();
|
||||
|
||||
const itemData = useExplorerItemData(props.data);
|
||||
const filePath = getItemFilePath(props.data);
|
||||
|
||||
const { parent } = useExplorerContext();
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
const [loadState, setLoadState] = useState<{
|
||||
|
@ -72,14 +79,11 @@ export const FileThumb = memo((props: ThumbProps) => {
|
|||
}, [itemData, loadState]);
|
||||
|
||||
const src = useMemo(() => {
|
||||
const locationId =
|
||||
itemData.locationId ?? (parent?.type === 'Location' ? parent.location.id : null);
|
||||
|
||||
switch (thumbType.variant) {
|
||||
case 'original':
|
||||
if (filePath && (itemData.extension !== 'pdf' || pdfViewerEnabled())) {
|
||||
if ('id' in filePath && locationId)
|
||||
return platform.getFileUrl(library.uuid, locationId, filePath.id);
|
||||
if ('id' in filePath && itemData.locationId)
|
||||
return platform.getFileUrl(library.uuid, itemData.locationId, filePath.id);
|
||||
else if ('path' in filePath) return platform.getFileUrlByPath(filePath.path);
|
||||
}
|
||||
break;
|
||||
|
@ -100,7 +104,7 @@ export const FileThumb = memo((props: ThumbProps) => {
|
|||
itemData.isDir
|
||||
);
|
||||
}
|
||||
}, [filePath, isDark, library.uuid, itemData, platform, thumbType, parent]);
|
||||
}, [filePath, isDark, library.uuid, itemData, platform, thumbType]);
|
||||
|
||||
const onLoad = (s: 'original' | 'thumbnail' | 'icon') => {
|
||||
setLoadState((state) => ({ ...state, [s]: 'loaded' }));
|
||||
|
@ -133,12 +137,14 @@ export const FileThumb = memo((props: ThumbProps) => {
|
|||
const className = clsx(childClassName, _childClassName);
|
||||
|
||||
const thumbnail = (() => {
|
||||
if (!src) return null;
|
||||
if (!src) return <></>;
|
||||
|
||||
switch (thumbType.variant) {
|
||||
case 'thumbnail':
|
||||
return (
|
||||
<Thumbnail
|
||||
{...props.childProps}
|
||||
ref={ref}
|
||||
src={src}
|
||||
cover={props.cover}
|
||||
onLoad={() => onLoad('thumbnail')}
|
||||
|
@ -169,6 +175,8 @@ export const FileThumb = memo((props: ThumbProps) => {
|
|||
case 'icon':
|
||||
return (
|
||||
<LayeredFileIcon
|
||||
{...props.childProps}
|
||||
ref={ref}
|
||||
src={src}
|
||||
kind={itemData.kind}
|
||||
extension={itemData.extension}
|
||||
|
@ -180,7 +188,7 @@ export const FileThumb = memo((props: ThumbProps) => {
|
|||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
return <></>;
|
||||
}
|
||||
})();
|
||||
|
||||
|
@ -233,17 +241,16 @@ interface ThumbnailProps extends Omit<ImageProps, 'blackBarsStyle' | 'size'> {
|
|||
extension?: string;
|
||||
}
|
||||
|
||||
const Thumbnail = memo(
|
||||
({
|
||||
crossOrigin,
|
||||
blackBars,
|
||||
blackBarsSize,
|
||||
extension,
|
||||
cover,
|
||||
className,
|
||||
...props
|
||||
}: ThumbnailProps) => {
|
||||
const Thumbnail = forwardRef<HTMLImageElement, ThumbnailProps>(
|
||||
(
|
||||
{ crossOrigin, blackBars, blackBarsSize, extension, cover, className, style, ...props },
|
||||
_ref
|
||||
) => {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
useImperativeHandle<HTMLImageElement | null, HTMLImageElement | null>(
|
||||
_ref,
|
||||
() => ref.current
|
||||
);
|
||||
|
||||
const size = useSize(ref);
|
||||
|
||||
|
@ -259,7 +266,7 @@ const Thumbnail = memo(
|
|||
blackBarsStyle && size.width === 0 && 'invisible'
|
||||
),
|
||||
cover,
|
||||
style: blackBars ? blackBarsStyle : undefined,
|
||||
style: { ...style, ...(blackBars ? blackBarsStyle : undefined) },
|
||||
size,
|
||||
ref
|
||||
}}
|
||||
|
@ -274,7 +281,7 @@ const Thumbnail = memo(
|
|||
})
|
||||
}}
|
||||
className={clsx(
|
||||
'absolute rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase text-white opacity-70',
|
||||
'pointer-events-none absolute rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase text-white opacity-70',
|
||||
cover
|
||||
? 'bottom-1 right-1'
|
||||
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full'
|
||||
|
|
|
@ -400,7 +400,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
|
|||
const { libraryId } = useZodRouteParams(LibraryIdParamsSchema);
|
||||
|
||||
const tagsQuery = useLibraryQuery(['tags.list'], {
|
||||
enabled: readyToFetch && !explorerStore.isDragging,
|
||||
enabled: readyToFetch && !explorerStore.isDragSelecting,
|
||||
suspense: true
|
||||
});
|
||||
useNodes(tagsQuery.data?.nodes);
|
||||
|
@ -408,7 +408,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
|
|||
|
||||
const tagsWithObjects = useLibraryQuery(
|
||||
['tags.getWithObjects', selectedObjects.map(({ id }) => id)],
|
||||
{ enabled: readyToFetch && !explorerStore.isDragging }
|
||||
{ enabled: readyToFetch && !explorerStore.isDragSelecting }
|
||||
);
|
||||
|
||||
const getDate = useCallback((metadataDate: MetadataDate, date: Date) => {
|
||||
|
|
|
@ -1,18 +1,10 @@
|
|||
import { createContext, useContext, type ReactNode, type RefObject } from 'react';
|
||||
|
||||
import { ExplorerViewPadding } from './View';
|
||||
|
||||
export interface ExplorerViewContext {
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
contextMenu?: ReactNode;
|
||||
isContextMenuOpen?: boolean;
|
||||
setIsContextMenuOpen?: (isOpen: boolean) => void;
|
||||
isRenaming: boolean;
|
||||
setIsRenaming: (isRenaming: boolean) => void;
|
||||
padding?: Omit<ExplorerViewPadding, 'x' | 'y'>;
|
||||
gap?: number | { x?: number; y?: number };
|
||||
selectable: boolean;
|
||||
listViewOptions?: {
|
||||
hideHeaderBorder?: boolean;
|
33
interface/app/$libraryId/Explorer/View/DragScrollable.tsx
Normal file
33
interface/app/$libraryId/Explorer/View/DragScrollable.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useExplorerLayoutStore } from '@sd/client';
|
||||
import { tw } from '@sd/ui';
|
||||
|
||||
import { useTopBarContext } from '../../TopBar/Layout';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { PATH_BAR_HEIGHT } from '../ExplorerPath';
|
||||
import { useDragScrollable } from './useDragScrollable';
|
||||
|
||||
const Trigger = tw.div`absolute inset-x-0 h-10 pointer-events-none`;
|
||||
|
||||
export const DragScrollable = () => {
|
||||
const topBar = useTopBarContext();
|
||||
const explorer = useExplorerContext();
|
||||
const explorerSettings = explorer.useSettingsSnapshot();
|
||||
|
||||
const layoutStore = useExplorerLayoutStore();
|
||||
const showPathBar = explorer.showPathBar && layoutStore.showPathBar;
|
||||
|
||||
const { ref: dragScrollableUpRef } = useDragScrollable({ direction: 'up' });
|
||||
const { ref: dragScrollableDownRef } = useDragScrollable({ direction: 'down' });
|
||||
|
||||
return (
|
||||
<>
|
||||
{explorerSettings.layoutMode !== 'list' && (
|
||||
<Trigger ref={dragScrollableUpRef} style={{ top: topBar.topBarHeight }} />
|
||||
)}
|
||||
<Trigger
|
||||
ref={dragScrollableDownRef}
|
||||
style={{ bottom: showPathBar ? PATH_BAR_HEIGHT : 0 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
41
interface/app/$libraryId/Explorer/View/EmptyNotice.tsx
Normal file
41
interface/app/$libraryId/Explorer/View/EmptyNotice.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { Columns, GridFour, Icon, MonitorPlay, Rows } from '@phosphor-icons/react';
|
||||
import { isValidElement, ReactNode } from 'react';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
|
||||
export const EmptyNotice = (props: {
|
||||
icon?: Icon | ReactNode;
|
||||
message?: ReactNode;
|
||||
loading?: boolean;
|
||||
}) => {
|
||||
const { layoutMode } = useExplorerContext().useSettingsSnapshot();
|
||||
|
||||
const emptyNoticeIcon = (icon?: Icon) => {
|
||||
const Icon =
|
||||
icon ??
|
||||
{
|
||||
grid: GridFour,
|
||||
media: MonitorPlay,
|
||||
columns: Columns,
|
||||
list: Rows
|
||||
}[layoutMode];
|
||||
|
||||
return <Icon size={100} opacity={0.3} />;
|
||||
};
|
||||
|
||||
if (props.loading) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
|
||||
{props.icon
|
||||
? isValidElement(props.icon)
|
||||
? props.icon
|
||||
: emptyNoticeIcon(props.icon as Icon)
|
||||
: emptyNoticeIcon()}
|
||||
|
||||
<p className="mt-5 text-sm font-medium">
|
||||
{props.message !== undefined ? props.message : 'This list is empty'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,255 +0,0 @@
|
|||
import { CaretRight } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useMatch, useNavigate } from 'react-router';
|
||||
import { ExplorerItem, FilePath, FilePathWithObject, useLibraryQuery } from '@sd/client';
|
||||
import { LibraryIdParamsSchema, SearchParamsSchema } from '~/app/route-schemas';
|
||||
import { Icon } from '~/components';
|
||||
import { useOperatingSystem, useZodRouteParams, useZodSearchParams } from '~/hooks';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { useExplorerSearchParams } from '../util';
|
||||
|
||||
export const PATH_BAR_HEIGHT = 32;
|
||||
|
||||
export const ExplorerPath = memo(() => {
|
||||
const isEphemeralLocation = useMatch('/:libraryId/ephemeral/:ephemeralId');
|
||||
const os = useOperatingSystem();
|
||||
const realOs = useOperatingSystem(true);
|
||||
const navigate = useNavigate();
|
||||
const libraryId = useZodRouteParams(LibraryIdParamsSchema).libraryId;
|
||||
const pathSlashOS = os === 'browser' ? '/' : realOs === 'windows' ? '\\' : '/';
|
||||
const firstRenderCached = useRef<null | boolean>(null);
|
||||
|
||||
const explorerContext = useExplorerContext();
|
||||
const fullPathOnClick = explorerContext.parent?.type === 'Tag';
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
const [_, setSearchParams] = useZodSearchParams(SearchParamsSchema);
|
||||
const selectedItem = useMemo(() => {
|
||||
if (explorerContext.selectedItems.size !== 1) return;
|
||||
return [...explorerContext.selectedItems][0];
|
||||
}, [explorerContext.selectedItems]);
|
||||
|
||||
// On initial render, check if the location is nested
|
||||
// If it is, return the number of times it is nested
|
||||
const isLocationNested = useCallback(() => {
|
||||
if (!explorerContext.parent || explorerContext.parent.type !== 'Location') return false;
|
||||
firstRenderCached.current = true;
|
||||
const { path: locationPath, name: locationName } = explorerContext.parent.location || {};
|
||||
|
||||
if (!locationPath || !locationName) return false;
|
||||
const count = locationPath
|
||||
.split(pathSlashOS)
|
||||
.filter((part) => part === locationName).length;
|
||||
|
||||
return count > 1 ? count - 1 : false;
|
||||
}, [explorerContext.parent, pathSlashOS]);
|
||||
|
||||
// On the first render of a location, check if the location is nested
|
||||
useEffect(() => {
|
||||
if (explorerContext.parent?.type === 'Location') {
|
||||
isLocationNested();
|
||||
}
|
||||
return () => {
|
||||
firstRenderCached.current = null;
|
||||
};
|
||||
}, [explorerContext.parent, isLocationNested]);
|
||||
|
||||
const filePathData = () => {
|
||||
if (!selectedItem) return;
|
||||
let filePathData: FilePath | FilePathWithObject | null = null;
|
||||
const item = selectedItem as ExplorerItem;
|
||||
switch (item.type) {
|
||||
case 'Path': {
|
||||
filePathData = item.item;
|
||||
break;
|
||||
}
|
||||
case 'Object': {
|
||||
filePathData = item.item.file_paths[0] ?? null;
|
||||
break;
|
||||
}
|
||||
case 'SpacedropPeer': {
|
||||
// objectData = item.item as unknown as Object;
|
||||
// filePathData = item.item.file_paths[0] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return filePathData;
|
||||
};
|
||||
|
||||
//this is being used with tag page route - when clicking on an object
|
||||
//we get the full path of the object and use it to build the path bar
|
||||
const queriedFullPath = useLibraryQuery(['files.getPath', filePathData()?.id ?? -1], {
|
||||
enabled: selectedItem != null && fullPathOnClick
|
||||
});
|
||||
|
||||
const indexedPath = fullPathOnClick
|
||||
? queriedFullPath.data
|
||||
: explorerContext.parent?.type === 'Location' && explorerContext.parent.location.path;
|
||||
|
||||
//There are cases where the path ends with a '/' and cases where it doesn't
|
||||
const pathInfo = indexedPath
|
||||
? indexedPath + (path ? path.slice(0, -1) : '')
|
||||
: path?.endsWith(pathSlashOS)
|
||||
? path?.slice(0, -1)
|
||||
: path;
|
||||
|
||||
const pathBuilder = (pathsToSplit: string, clickedPath: string): string => {
|
||||
const slashCheck = isEphemeralLocation ? pathSlashOS : '/'; //in ephemeral locations, the path is built with '\' instead of '/' for windows
|
||||
const splitPaths = pathsToSplit?.split(slashCheck);
|
||||
const indexOfClickedPath = splitPaths?.indexOf(clickedPath);
|
||||
const newPath =
|
||||
splitPaths?.slice(0, (indexOfClickedPath as number) + 1).join(slashCheck) + slashCheck;
|
||||
return newPath;
|
||||
};
|
||||
|
||||
const pathRedirectHandler = (pathName: string, index: number): void => {
|
||||
let newPath: string | undefined;
|
||||
if (fullPathOnClick) {
|
||||
if (!explorerContext.selectedItems) return;
|
||||
const objectData = Array.from(explorerContext.selectedItems)[0];
|
||||
if (!objectData) return;
|
||||
if ('file_paths' in objectData.item && objectData) {
|
||||
newPath = pathBuilder(pathInfo as string, pathName);
|
||||
navigate({
|
||||
pathname: `/${libraryId}/ephemeral/0`,
|
||||
search: `?path=${newPath}`
|
||||
});
|
||||
}
|
||||
} else if (isEphemeralLocation) {
|
||||
const currentPaths = data?.map((p) => p.name).join(pathSlashOS);
|
||||
newPath = `${pathSlashOS}${pathBuilder(currentPaths as string, pathName)}`;
|
||||
setSearchParams((params) => ({ ...params, path: newPath }));
|
||||
} else {
|
||||
newPath = pathBuilder(path as string, pathName);
|
||||
setSearchParams((params) => ({ ...params, path: index === 0 ? '' : newPath }));
|
||||
}
|
||||
};
|
||||
|
||||
const pathNameLocationName =
|
||||
explorerContext.parent?.type === 'Location' && explorerContext.parent?.location.name;
|
||||
const data = useMemo(() => {
|
||||
if (!pathInfo) return;
|
||||
const splitPaths = pathInfo?.replaceAll('/', pathSlashOS).split(pathSlashOS); //replace all '/' with '\' for windows
|
||||
|
||||
//if the path is a full path
|
||||
if (fullPathOnClick && queriedFullPath.data) {
|
||||
if (!selectedItem) return;
|
||||
const selectedItemFilePaths =
|
||||
'file_paths' in selectedItem.item && selectedItem.item.file_paths[0];
|
||||
if (!selectedItemFilePaths) return;
|
||||
const updatedData = splitPaths
|
||||
.map((path) => ({
|
||||
kind: 'Folder',
|
||||
extension: '',
|
||||
name: path
|
||||
}))
|
||||
//remove duplicate path names upon selection + from the result of the full path query
|
||||
.filter(
|
||||
(path) =>
|
||||
path.name !==
|
||||
`${selectedItemFilePaths.name}.${selectedItemFilePaths.extension}` &&
|
||||
path.name !== '' &&
|
||||
path.name !== selectedItemFilePaths.name
|
||||
);
|
||||
return updatedData;
|
||||
|
||||
//handling ephemeral and location paths
|
||||
} else {
|
||||
let updatedPathData: string[] = [];
|
||||
const nestedCount = isLocationNested();
|
||||
const startIndex = isEphemeralLocation
|
||||
? 1
|
||||
: pathNameLocationName
|
||||
? splitPaths.indexOf(pathNameLocationName)
|
||||
: -1;
|
||||
if (nestedCount) {
|
||||
updatedPathData = splitPaths.slice(startIndex + nestedCount);
|
||||
} else updatedPathData = splitPaths.slice(startIndex);
|
||||
const updatedData = updatedPathData.map((path) => ({
|
||||
kind: 'Folder',
|
||||
extension: '',
|
||||
name: path
|
||||
}));
|
||||
return updatedData;
|
||||
}
|
||||
}, [
|
||||
pathInfo,
|
||||
isLocationNested,
|
||||
pathSlashOS,
|
||||
isEphemeralLocation,
|
||||
pathNameLocationName,
|
||||
fullPathOnClick,
|
||||
queriedFullPath.data,
|
||||
selectedItem
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex items-center gap-1 border-t border-t-app-line bg-app/90 px-3.5 text-[11px] text-ink-dull backdrop-blur-lg"
|
||||
style={{ height: PATH_BAR_HEIGHT }}
|
||||
>
|
||||
{data?.map((p, index) => {
|
||||
return (
|
||||
<Path
|
||||
key={(p.name + index).toString()}
|
||||
paths={data.map((p) => p.name)}
|
||||
path={p}
|
||||
index={index}
|
||||
fullPathOnClick={fullPathOnClick}
|
||||
onClick={() => pathRedirectHandler(p.name, index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{selectedItem && (
|
||||
<div className="pointer-events-none flex items-center gap-1">
|
||||
{data && data.length > 0 && <CaretRight weight="bold" size={10} />}
|
||||
<>
|
||||
<FileThumb size={16} frame frameClassName="!border" data={selectedItem} />
|
||||
{'name' in selectedItem.item ? (
|
||||
<span className="max-w-xs truncate text-ink-dull">
|
||||
{selectedItem.item.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="max-w-xs truncate">
|
||||
{selectedItem.item.file_paths[0]?.name}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface Props extends ComponentProps<'div'> {
|
||||
paths: string[];
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
fullPathOnClick: boolean;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const Path = ({ paths, path, fullPathOnClick, index, ...rest }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-1',
|
||||
fullPathOnClick
|
||||
? 'cursor-pointer text-ink-dull'
|
||||
: index !== paths.length - 1 && ' cursor-pointer'
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<Icon name="Folder" size={16} alt="Folder" />
|
||||
<span className="max-w-xs truncate text-ink-dull transition-opacity duration-300 hover:opacity-80">
|
||||
{path.name}
|
||||
</span>
|
||||
{index !== (paths?.length as number) - 1 && (
|
||||
<CaretRight weight="bold" className="text-ink-dull" size={10} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
86
interface/app/$libraryId/Explorer/View/Grid/Item.tsx
Normal file
86
interface/app/$libraryId/Explorer/View/Grid/Item.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { HTMLAttributes, useEffect, useMemo } from 'react';
|
||||
import { type ExplorerItem } from '@sd/client';
|
||||
|
||||
import { RenderItem } from '.';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { getExplorerStore, isCut } from '../../store';
|
||||
import { uniqueId } from '../../util';
|
||||
import { useExplorerViewContext } from '../Context';
|
||||
import { useGridContext } from './context';
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
index: number;
|
||||
item: ExplorerItem;
|
||||
children: RenderItem;
|
||||
}
|
||||
|
||||
export const GridItem = ({ children, item, ...props }: Props) => {
|
||||
const grid = useGridContext();
|
||||
const explorer = useExplorerContext();
|
||||
const explorerView = useExplorerViewContext();
|
||||
const explorerStore = getExplorerStore();
|
||||
|
||||
const itemId = useMemo(() => uniqueId(item), [item]);
|
||||
|
||||
const selected = useMemo(
|
||||
// Even though this checks object equality, it should still be safe since `selectedItems`
|
||||
// will be re-calculated before this memo runs.
|
||||
() => explorer.selectedItems.has(item),
|
||||
[explorer.selectedItems, item]
|
||||
);
|
||||
|
||||
const cut = useMemo(
|
||||
() => isCut(item, explorerStore.cutCopyState),
|
||||
[explorerStore.cutCopyState, item]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!grid.selecto?.current || !grid.selectoUnselected.current.has(itemId)) return;
|
||||
|
||||
if (!selected) {
|
||||
grid.selectoUnselected.current.delete(itemId);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = grid.getElementById(itemId);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
grid.selectoUnselected.current.delete(itemId);
|
||||
grid.selecto.current.setSelectedTargets([
|
||||
...grid.selecto.current.getSelectedTargets(),
|
||||
element as HTMLElement
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!grid.selecto) return;
|
||||
|
||||
return () => {
|
||||
const element = grid.getElementById(itemId);
|
||||
if (selected && !element) grid.selectoUnselected.current.add(itemId);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className="h-full w-full"
|
||||
data-selectable=""
|
||||
data-selectable-index={props.index}
|
||||
data-selectable-id={itemId}
|
||||
onContextMenu={(e) => {
|
||||
if (explorerView.selectable && !explorer.selectedItems.has(item)) {
|
||||
explorer.resetSelectedItems([item]);
|
||||
grid.selecto?.current?.setSelectedTargets([e.currentTarget]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children({ item: item, selected, cut })}
|
||||
</div>
|
||||
);
|
||||
};
|
18
interface/app/$libraryId/Explorer/View/Grid/context.tsx
Normal file
18
interface/app/$libraryId/Explorer/View/Grid/context.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import Selecto from 'react-selecto';
|
||||
|
||||
interface GridContext {
|
||||
selecto?: React.RefObject<Selecto>;
|
||||
selectoUnselected: React.MutableRefObject<Set<string>>;
|
||||
getElementById: (id: string) => Element | null | undefined;
|
||||
}
|
||||
|
||||
export const GridContext = createContext<GridContext | null>(null);
|
||||
|
||||
export const useGridContext = () => {
|
||||
const ctx = useContext(GridContext);
|
||||
|
||||
if (ctx === null) throw new Error('GridContext.Provider not found!');
|
||||
|
||||
return ctx;
|
||||
};
|
|
@ -1,138 +1,63 @@
|
|||
import { Grid, useGrid } from '@virtual-grid/react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react';
|
||||
import { memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Selecto from 'react-selecto';
|
||||
import { type ExplorerItem } from '@sd/client';
|
||||
import { useOperatingSystem, useShortcut } from '~/hooks';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { getQuickPreviewStore, useQuickPreviewStore } from '../QuickPreview/store';
|
||||
import { getExplorerStore, isCut, useExplorerStore } from '../store';
|
||||
import { uniqueId } from '../util';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { getQuickPreviewStore, useQuickPreviewStore } from '../../QuickPreview/store';
|
||||
import { getExplorerStore } from '../../store';
|
||||
import { uniqueId } from '../../util';
|
||||
import { useExplorerViewContext } from '../Context';
|
||||
import { GridContext } from './context';
|
||||
import { GridItem } from './Item';
|
||||
|
||||
const SelectoContext = createContext<{
|
||||
selecto: React.RefObject<Selecto>;
|
||||
selectoUnSelected: React.MutableRefObject<Set<string>>;
|
||||
} | null>(null);
|
||||
|
||||
type RenderItem = (item: { item: ExplorerItem; selected: boolean; cut: boolean }) => ReactNode;
|
||||
|
||||
const GridListItem = (props: {
|
||||
index: number;
|
||||
export type RenderItem = (item: {
|
||||
item: ExplorerItem;
|
||||
children: RenderItem;
|
||||
onMouseDown: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
getElementById: (id: string) => Element | null | undefined;
|
||||
}) => {
|
||||
const explorer = useExplorerContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
const selecto = useContext(SelectoContext);
|
||||
|
||||
const cut = isCut(props.item, explorerStore.cutCopyState);
|
||||
|
||||
const selected = useMemo(
|
||||
// Even though this checks object equality, it should still be safe since `selectedItems`
|
||||
// will be re-calculated before this memo runs.
|
||||
() => explorer.selectedItems.has(props.item),
|
||||
[explorer.selectedItems, props.item]
|
||||
);
|
||||
|
||||
const itemId = uniqueId(props.item);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecto?.selecto.current || !selecto.selectoUnSelected.current.has(itemId)) return;
|
||||
|
||||
if (!selected) {
|
||||
selecto.selectoUnSelected.current.delete(itemId);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = props.getElementById(itemId);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
selecto.selectoUnSelected.current.delete(itemId);
|
||||
selecto.selecto.current.setSelectedTargets([
|
||||
...selecto.selecto.current.getSelectedTargets(),
|
||||
element as HTMLElement
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selecto) return;
|
||||
|
||||
return () => {
|
||||
const element = props.getElementById(itemId);
|
||||
if (selected && !element) selecto.selectoUnSelected.current.add(itemId);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
data-selectable=""
|
||||
data-selectable-index={props.index}
|
||||
data-selectable-id={itemId}
|
||||
onMouseDown={props.onMouseDown}
|
||||
onContextMenu={(e) => {
|
||||
if (explorerView.selectable && !explorer.selectedItems.has(props.item)) {
|
||||
explorer.resetSelectedItems([props.item]);
|
||||
selecto?.selecto.current?.setSelectedTargets([e.currentTarget]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children({ item: props.item, selected, cut })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
selected: boolean;
|
||||
cut: boolean;
|
||||
}) => ReactNode;
|
||||
|
||||
const CHROME_REGEX = /Chrome/;
|
||||
|
||||
export default ({ children }: { children: RenderItem }) => {
|
||||
export default memo(({ children }: { children: RenderItem }) => {
|
||||
const os = useOperatingSystem();
|
||||
const realOS = useOperatingSystem(true);
|
||||
|
||||
const isChrome = CHROME_REGEX.test(navigator.userAgent);
|
||||
|
||||
const explorer = useExplorerContext();
|
||||
const settings = explorer.useSettingsSnapshot();
|
||||
const explorerView = useExplorerViewContext();
|
||||
const explorerSettings = explorer.useSettingsSnapshot();
|
||||
const quickPreviewStore = useQuickPreviewStore();
|
||||
|
||||
const selecto = useRef<Selecto>(null);
|
||||
const selectoUnSelected = useRef<Set<string>>(new Set());
|
||||
const selectoUnselected = useRef<Set<string>>(new Set());
|
||||
const selectoFirstColumn = useRef<number | undefined>();
|
||||
const selectoLastColumn = useRef<number | undefined>();
|
||||
|
||||
// The item that further selection will move from (shift + arrow for example).
|
||||
// This used to be calculated from the last item of selectedItems,
|
||||
// but Set ordering isn't reliable.
|
||||
// Ref bc we never actually render this.
|
||||
const activeItem = useRef<ExplorerItem | null>(null);
|
||||
|
||||
const [dragFromThumbnail, setDragFromThumbnail] = useState(false);
|
||||
|
||||
const itemDetailsHeight = 44 + (settings.showBytesInGridView ? 20 : 0);
|
||||
const itemHeight = settings.gridItemSize + itemDetailsHeight;
|
||||
|
||||
const padding = settings.layoutMode === 'grid' ? 12 : 0;
|
||||
const itemDetailsHeight = 44 + (explorerSettings.showBytesInGridView ? 20 : 0);
|
||||
const itemHeight = explorerSettings.gridItemSize + itemDetailsHeight;
|
||||
const padding = explorerSettings.layoutMode === 'grid' ? 12 : 0;
|
||||
|
||||
const grid = useGrid({
|
||||
scrollRef: explorer.scrollRef,
|
||||
count: explorer.items?.length ?? 0,
|
||||
totalCount: explorer.count,
|
||||
...(settings.layoutMode === 'grid'
|
||||
? { columns: 'auto', size: { width: settings.gridItemSize, height: itemHeight } }
|
||||
: { columns: settings.mediaColumns }),
|
||||
...(explorerSettings.layoutMode === 'grid'
|
||||
? {
|
||||
columns: 'auto',
|
||||
size: { width: explorerSettings.gridItemSize, height: itemHeight }
|
||||
}
|
||||
: { columns: explorerSettings.mediaColumns }),
|
||||
rowVirtualizer: { overscan: explorer.overscan ?? 5 },
|
||||
onLoadMore: explorer.loadMore,
|
||||
getItemId: useCallback(
|
||||
|
@ -144,14 +69,11 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
),
|
||||
getItemData: useCallback((index: number) => explorer.items?.[index], [explorer.items]),
|
||||
padding: {
|
||||
...explorerView.padding,
|
||||
bottom: explorerView.bottom
|
||||
? (explorerView.padding?.bottom ?? padding) + explorerView.bottom
|
||||
: undefined,
|
||||
bottom: explorerView.bottom ? padding + explorerView.bottom : undefined,
|
||||
x: padding,
|
||||
y: padding
|
||||
},
|
||||
gap: explorerView.gap || (settings.layoutMode === 'grid' ? settings.gridGap : undefined)
|
||||
gap: explorerSettings.layoutMode === 'grid' ? explorerSettings.gridGap : 1
|
||||
});
|
||||
|
||||
const getElementById = useCallback(
|
||||
|
@ -201,6 +123,16 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
return activeItem;
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
getExplorerStore().isDragSelecting = false;
|
||||
selectoFirstColumn.current = undefined;
|
||||
selectoLastColumn.current = undefined;
|
||||
setDragFromThumbnail(false);
|
||||
|
||||
const allSelected = selecto.current?.getSelectedTargets() ?? [];
|
||||
activeItem.current = getActiveItem(allSelected);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const element = explorer.scrollRef.current;
|
||||
|
@ -234,7 +166,7 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
return selected;
|
||||
});
|
||||
|
||||
selectoUnSelected.current = set;
|
||||
selectoUnselected.current = set;
|
||||
selecto.current.setSelectedTargets(items as HTMLElement[]);
|
||||
|
||||
activeItem.current = getActiveItem(items);
|
||||
|
@ -242,16 +174,10 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [grid.columnCount, explorer.items]);
|
||||
|
||||
// The item that further selection will move from (shift + arrow for example).
|
||||
// This used to be calculated from the last item of selectedItems,
|
||||
// but Set ordering isn't reliable.
|
||||
// Ref bc we never actually render this.
|
||||
const activeItem = useRef<ExplorerItem | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (explorer.selectedItems.size !== 0) return;
|
||||
|
||||
selectoUnSelected.current = new Set();
|
||||
selectoUnselected.current = new Set();
|
||||
// Accessing refs during render is bad
|
||||
activeItem.current = null;
|
||||
}, [explorer.selectedItems]);
|
||||
|
@ -289,7 +215,7 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
} else {
|
||||
explorer.resetSelectedItems([newSelectedItem.data]);
|
||||
selecto.current?.setSelectedTargets([selectedItemElement as HTMLElement]);
|
||||
if (selectoUnSelected.current.size > 0) selectoUnSelected.current = new Set();
|
||||
if (selectoUnselected.current.size > 0) selectoUnselected.current = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -414,14 +340,14 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
|
||||
const element = getElementById(itemId);
|
||||
|
||||
if (!element) selectoUnSelected.current = new Set(itemId);
|
||||
if (!element) selectoUnselected.current = new Set(itemId);
|
||||
else selecto.current.setSelectedTargets([element as HTMLElement]);
|
||||
|
||||
activeItem.current = item;
|
||||
}, [explorer.items, explorer.selectedItems, quickPreviewStore.open, realOS, getElementById]);
|
||||
|
||||
return (
|
||||
<SelectoContext.Provider value={selecto.current ? { selecto, selectoUnSelected } : null}>
|
||||
<GridContext.Provider value={{ selecto, selectoUnselected, getElementById }}>
|
||||
{explorer.allowMultiSelect && (
|
||||
<Selecto
|
||||
ref={selecto}
|
||||
|
@ -437,22 +363,19 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
selectableTargets={['[data-selectable]']}
|
||||
toggleContinueSelect="shift"
|
||||
hitRate={0}
|
||||
// selectFromInside={explorerStore.layoutMode === 'media'}
|
||||
onDragStart={(e) => {
|
||||
getExplorerStore().isDragging = true;
|
||||
if ((e.inputEvent as MouseEvent).target instanceof HTMLImageElement) {
|
||||
onDrag={(e) => {
|
||||
if (!getExplorerStore().drag) return;
|
||||
e.stop();
|
||||
handleDragEnd();
|
||||
}}
|
||||
onDragStart={({ inputEvent }) => {
|
||||
getExplorerStore().isDragSelecting = true;
|
||||
|
||||
if ((inputEvent as MouseEvent).target instanceof HTMLImageElement) {
|
||||
setDragFromThumbnail(true);
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
getExplorerStore().isDragging = false;
|
||||
selectoFirstColumn.current = undefined;
|
||||
selectoLastColumn.current = undefined;
|
||||
setDragFromThumbnail(false);
|
||||
|
||||
const allSelected = selecto.current?.getSelectedTargets() ?? [];
|
||||
activeItem.current = getActiveItem(allSelected);
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
onScroll={({ direction }) => {
|
||||
selecto.current?.findSelectableTargets();
|
||||
explorer.scrollRef.current?.scrollBy(
|
||||
|
@ -482,7 +405,7 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
if (explorer.selectedItems.has(item.data)) {
|
||||
selecto.current?.setSelectedTargets(e.beforeSelected);
|
||||
} else {
|
||||
selectoUnSelected.current = new Set();
|
||||
selectoUnselected.current = new Set();
|
||||
explorer.resetSelectedItems([item.data]);
|
||||
}
|
||||
|
||||
|
@ -657,8 +580,8 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
}
|
||||
|
||||
if (unselectedItems.length > 0) {
|
||||
selectoUnSelected.current = new Set([
|
||||
...selectoUnSelected.current,
|
||||
selectoUnselected.current = new Set([
|
||||
...selectoUnselected.current,
|
||||
...unselectedItems
|
||||
]);
|
||||
}
|
||||
|
@ -673,11 +596,10 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<GridListItem
|
||||
<GridItem
|
||||
key={uniqueId(item)}
|
||||
index={index}
|
||||
item={item}
|
||||
getElementById={getElementById}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0 || !explorerView.selectable) return;
|
||||
|
||||
|
@ -698,10 +620,10 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
}}
|
||||
>
|
||||
{children}
|
||||
</GridListItem>
|
||||
</GridItem>
|
||||
);
|
||||
}}
|
||||
</Grid>
|
||||
</SelectoContext.Provider>
|
||||
</GridContext.Provider>
|
||||
);
|
||||
};
|
||||
});
|
|
@ -1,89 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import { memo } from 'react';
|
||||
import { useMatch } from 'react-router';
|
||||
import {
|
||||
byteSize,
|
||||
getExplorerItemData,
|
||||
getItemFilePath,
|
||||
getItemLocation,
|
||||
type ExplorerItem
|
||||
} from '@sd/client';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import GridList from './GridList';
|
||||
import { RenamableItemText } from './RenamableItemText';
|
||||
import { ViewItem } from './ViewItem';
|
||||
|
||||
interface GridViewItemProps {
|
||||
data: ExplorerItem;
|
||||
selected: boolean;
|
||||
isRenaming: boolean;
|
||||
cut: boolean;
|
||||
}
|
||||
|
||||
const GridViewItem = memo(({ data, selected, cut, isRenaming }: GridViewItemProps) => {
|
||||
const explorer = useExplorerContext();
|
||||
const { showBytesInGridView } = explorer.useSettingsSnapshot();
|
||||
|
||||
const explorerItemData = getExplorerItemData(data);
|
||||
const filePathData = getItemFilePath(data);
|
||||
const location = getItemLocation(data);
|
||||
const isEphemeralLocation = useMatch('/:libraryId/ephemeral/:ephemeralId');
|
||||
const isFolder = filePathData?.is_dir;
|
||||
const hidden = filePathData?.hidden;
|
||||
|
||||
const showSize =
|
||||
showBytesInGridView &&
|
||||
!location &&
|
||||
!isFolder &&
|
||||
(!isEphemeralLocation || !isFolder) &&
|
||||
(!isRenaming || !selected);
|
||||
|
||||
return (
|
||||
<ViewItem data={data} className={clsx('h-full w-full', hidden && 'opacity-50')}>
|
||||
<div
|
||||
className={clsx('mb-1 aspect-square rounded-lg', selected && 'bg-app-selectedItem')}
|
||||
>
|
||||
<FileThumb
|
||||
data={data}
|
||||
frame={explorerItemData.kind !== 'Video'}
|
||||
extension
|
||||
blackBars
|
||||
className={clsx('px-2 py-1', cut && 'opacity-60')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center">
|
||||
<RenamableItemText item={data} style={{ maxHeight: 40 }} lines={2} />
|
||||
{showSize && filePathData?.size_in_bytes_bytes && (
|
||||
<span
|
||||
className={clsx(
|
||||
'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-tiny text-ink-dull'
|
||||
)}
|
||||
>
|
||||
{`${byteSize(filePathData.size_in_bytes_bytes)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ViewItem>
|
||||
);
|
||||
});
|
||||
|
||||
export default () => {
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
return (
|
||||
<GridList>
|
||||
{({ item, selected, cut }) => (
|
||||
<GridViewItem
|
||||
data={item}
|
||||
selected={selected}
|
||||
cut={cut}
|
||||
isRenaming={explorerView.isRenaming}
|
||||
/>
|
||||
)}
|
||||
</GridList>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
import { GridViewItemProps } from '.';
|
||||
|
||||
export const GridViewItemContext = createContext<GridViewItemProps | null>(null);
|
||||
|
||||
export const useGridViewItemContext = () => {
|
||||
const ctx = useContext(GridViewItemContext);
|
||||
|
||||
if (ctx === null) throw new Error('GridViewItemContext.Provider not found!');
|
||||
|
||||
return ctx;
|
||||
};
|
128
interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx
Normal file
128
interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx
Normal file
|
@ -0,0 +1,128 @@
|
|||
import clsx from 'clsx';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { byteSize, getItemFilePath, type ExplorerItem } from '@sd/client';
|
||||
|
||||
import { useExplorerContext } from '../../../Context';
|
||||
import { ExplorerDraggable } from '../../../ExplorerDraggable';
|
||||
import { ExplorerDroppable, useExplorerDroppableContext } from '../../../ExplorerDroppable';
|
||||
import { FileThumb } from '../../../FilePath/Thumb';
|
||||
import { useExplorerStore } from '../../../store';
|
||||
import { useExplorerDraggable } from '../../../useExplorerDraggable';
|
||||
import { RenamableItemText } from '../../RenamableItemText';
|
||||
import { ViewItem } from '../../ViewItem';
|
||||
import { GridViewItemContext, useGridViewItemContext } from './Context';
|
||||
|
||||
export interface GridViewItemProps {
|
||||
data: ExplorerItem;
|
||||
selected: boolean;
|
||||
cut: boolean;
|
||||
}
|
||||
|
||||
export const GridViewItem = memo((props: GridViewItemProps) => {
|
||||
const filePath = getItemFilePath(props.data);
|
||||
|
||||
const isHidden = filePath?.hidden;
|
||||
const isFolder = filePath?.is_dir;
|
||||
const isLocation = props.data.type === 'Location';
|
||||
|
||||
return (
|
||||
<GridViewItemContext.Provider value={props}>
|
||||
<ViewItem data={props.data} className={clsx('h-full w-full', isHidden && 'opacity-50')}>
|
||||
<ExplorerDroppable
|
||||
droppable={{
|
||||
data: { type: 'explorer-item', data: props.data },
|
||||
disabled: (!isFolder && !isLocation) || props.selected
|
||||
}}
|
||||
>
|
||||
<InnerDroppable />
|
||||
</ExplorerDroppable>
|
||||
</ViewItem>
|
||||
</GridViewItemContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
const InnerDroppable = () => {
|
||||
const item = useGridViewItemContext();
|
||||
const { isDroppable } = useExplorerDroppableContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'mb-1 aspect-square rounded-lg',
|
||||
(item.selected || isDroppable) && 'bg-app-selectedItem'
|
||||
)}
|
||||
>
|
||||
<ItemFileThumb />
|
||||
</div>
|
||||
|
||||
<ExplorerDraggable draggable={{ data: item.data }}>
|
||||
<RenamableItemText
|
||||
item={item.data}
|
||||
style={{ maxHeight: 40, textAlign: 'center' }}
|
||||
lines={2}
|
||||
highlight={isDroppable}
|
||||
selected={item.selected}
|
||||
/>
|
||||
<ItemSize />
|
||||
</ExplorerDraggable>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemFileThumb = () => {
|
||||
const item = useGridViewItemContext();
|
||||
|
||||
const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable({
|
||||
data: item.data
|
||||
});
|
||||
|
||||
return (
|
||||
<FileThumb
|
||||
data={item.data}
|
||||
frame
|
||||
blackBars
|
||||
extension
|
||||
className={clsx('px-2 py-1', item.cut && 'opacity-60')}
|
||||
ref={setDraggableRef}
|
||||
childProps={{
|
||||
style,
|
||||
...attributes,
|
||||
...listeners
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemSize = () => {
|
||||
const item = useGridViewItemContext();
|
||||
const { showBytesInGridView } = useExplorerContext().useSettingsSnapshot();
|
||||
const { isRenaming } = useExplorerStore();
|
||||
|
||||
const filePath = getItemFilePath(item.data);
|
||||
|
||||
const isLocation = item.data.type === 'Location';
|
||||
const isEphemeral = item.data.type === 'NonIndexedPath';
|
||||
const isFolder = filePath?.is_dir;
|
||||
|
||||
const showSize =
|
||||
showBytesInGridView &&
|
||||
filePath?.size_in_bytes_bytes &&
|
||||
!isLocation &&
|
||||
!isFolder &&
|
||||
(!isEphemeral || !isFolder) &&
|
||||
(!isRenaming || !item.selected);
|
||||
|
||||
const bytes = useMemo(
|
||||
() => showSize && byteSize(filePath?.size_in_bytes_bytes),
|
||||
[filePath?.size_in_bytes_bytes, showSize]
|
||||
);
|
||||
|
||||
if (!showSize) return null;
|
||||
|
||||
return (
|
||||
<div className="truncate rounded-md px-1.5 py-[1px] text-center text-tiny text-ink-dull">
|
||||
{`${bytes}`}
|
||||
</div>
|
||||
);
|
||||
};
|
12
interface/app/$libraryId/Explorer/View/GridView/index.tsx
Normal file
12
interface/app/$libraryId/Explorer/View/GridView/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import Grid from '../Grid';
|
||||
import { GridViewItem } from './Item';
|
||||
|
||||
export const GridView = () => {
|
||||
return (
|
||||
<Grid>
|
||||
{({ item, selected, cut }) => (
|
||||
<GridViewItem data={item} selected={selected} cut={cut} />
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
84
interface/app/$libraryId/Explorer/View/ListView/Item.tsx
Normal file
84
interface/app/$libraryId/Explorer/View/ListView/Item.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { flexRender, type Cell } from '@tanstack/react-table';
|
||||
import clsx from 'clsx';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { getItemFilePath, type ExplorerItem } from '@sd/client';
|
||||
|
||||
import { TABLE_PADDING_X } from '.';
|
||||
import { ExplorerDraggable } from '../../ExplorerDraggable';
|
||||
import { ExplorerDroppable, useExplorerDroppableContext } from '../../ExplorerDroppable';
|
||||
import { ViewItem } from '../ViewItem';
|
||||
import { useTableContext } from './context';
|
||||
|
||||
interface Props {
|
||||
data: ExplorerItem;
|
||||
selected: boolean;
|
||||
cells: Cell<ExplorerItem, unknown>[];
|
||||
}
|
||||
|
||||
export const ListViewItem = memo(({ data, selected, cells }: Props) => {
|
||||
const filePath = getItemFilePath(data);
|
||||
|
||||
return (
|
||||
<ViewItem
|
||||
data={data}
|
||||
className="flex h-full"
|
||||
style={{ paddingLeft: TABLE_PADDING_X, paddingRight: TABLE_PADDING_X }}
|
||||
>
|
||||
<ExplorerDroppable
|
||||
className="relative"
|
||||
droppable={{
|
||||
data: { type: 'explorer-item', data },
|
||||
disabled: (!filePath?.is_dir && data.type !== 'Location') || selected
|
||||
}}
|
||||
>
|
||||
<DroppableOverlay />
|
||||
<ExplorerDraggable
|
||||
draggable={{ data }}
|
||||
className={clsx('flex h-full items-center', filePath?.hidden && 'opacity-50')}
|
||||
>
|
||||
{cells.map((cell) => (
|
||||
<Cell key={cell.id} cell={cell} selected={selected} />
|
||||
))}
|
||||
</ExplorerDraggable>
|
||||
</ExplorerDroppable>
|
||||
</ViewItem>
|
||||
);
|
||||
});
|
||||
|
||||
const DroppableOverlay = () => {
|
||||
const { isDroppable } = useExplorerDroppableContext();
|
||||
if (!isDroppable) return null;
|
||||
|
||||
return <div className="absolute inset-0 rounded-md bg-accent/25" />;
|
||||
};
|
||||
|
||||
const Cell = ({ cell, selected }: { cell: Cell<ExplorerItem, unknown>; selected: boolean }) => {
|
||||
useTableContext(); // Force re-render for column sizing
|
||||
|
||||
return <InnerCell cell={cell} size={cell.column.getSize()} selected={selected} />;
|
||||
};
|
||||
|
||||
const InnerCell = memo(
|
||||
(props: { cell: Cell<ExplorerItem, unknown>; size: number; selected: boolean }) => {
|
||||
const value = useMemo(() => props.cell.getValue(), [props.cell]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={props.cell.id}
|
||||
className={clsx(
|
||||
'table-cell px-4 text-xs text-ink-dull',
|
||||
props.cell.column.id !== 'name' && 'truncate',
|
||||
props.cell.column.columnDef.meta?.className
|
||||
)}
|
||||
style={{ width: props.size }}
|
||||
>
|
||||
{value
|
||||
? `${value}`
|
||||
: flexRender(props.cell.column.columnDef.cell, {
|
||||
...props.cell.getContext(),
|
||||
selected: props.selected
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
57
interface/app/$libraryId/Explorer/View/ListView/TableRow.tsx
Normal file
57
interface/app/$libraryId/Explorer/View/ListView/TableRow.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { type Row } from '@tanstack/react-table';
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
import { type ExplorerItem } from '@sd/client';
|
||||
|
||||
import { TABLE_PADDING_X } from '.';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { ListViewItem } from './Item';
|
||||
|
||||
interface Props {
|
||||
row: Row<ExplorerItem>;
|
||||
previousRow?: Row<ExplorerItem>;
|
||||
nextRow?: Row<ExplorerItem>;
|
||||
}
|
||||
|
||||
export const TableRow = ({ row, previousRow, nextRow }: Props) => {
|
||||
const explorer = useExplorerContext();
|
||||
|
||||
const selected = useMemo(() => {
|
||||
return explorer.selectedItems.has(row.original);
|
||||
}, [explorer.selectedItems, row.original]);
|
||||
|
||||
const isPreviousRowSelected = useMemo(() => {
|
||||
if (!previousRow) return;
|
||||
return explorer.selectedItems.has(previousRow.original);
|
||||
}, [explorer.selectedItems, previousRow]);
|
||||
|
||||
const isNextRowSelected = useMemo(() => {
|
||||
if (!nextRow) return;
|
||||
return explorer.selectedItems.has(nextRow.original);
|
||||
}, [explorer.selectedItems, nextRow]);
|
||||
|
||||
const cells = row.getVisibleCells();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-0 rounded-md border',
|
||||
row.index % 2 === 0 && 'bg-app-darkBox',
|
||||
selected ? 'border-accent !bg-accent/10' : 'border-transparent',
|
||||
selected && [
|
||||
isPreviousRowSelected && 'rounded-t-none border-t-0 border-t-transparent',
|
||||
isNextRowSelected && 'rounded-b-none border-b-0 border-b-transparent'
|
||||
]
|
||||
)}
|
||||
style={{ left: TABLE_PADDING_X, right: TABLE_PADDING_X }}
|
||||
>
|
||||
{isPreviousRowSelected && (
|
||||
<div className="absolute inset-x-3 top-0 h-px bg-accent/10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ListViewItem data={row.original} selected={selected} cells={cells} />
|
||||
</>
|
||||
);
|
||||
};
|
16
interface/app/$libraryId/Explorer/View/ListView/context.tsx
Normal file
16
interface/app/$libraryId/Explorer/View/ListView/context.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { ColumnSizingState } from '@tanstack/react-table';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface TableContext {
|
||||
columnSizing: ColumnSizingState;
|
||||
}
|
||||
|
||||
export const TableContext = createContext<TableContext | null>(null);
|
||||
|
||||
export const useTableContext = () => {
|
||||
const ctx = useContext(TableContext);
|
||||
|
||||
if (ctx === null) throw new Error('TableContext.Provider not found!');
|
||||
|
||||
return ctx;
|
||||
};
|
|
@ -1,112 +1,46 @@
|
|||
import { CaretDown, CaretUp } from '@phosphor-icons/react';
|
||||
import {
|
||||
flexRender,
|
||||
VisibilityState,
|
||||
type ColumnSizingState,
|
||||
type Row
|
||||
} from '@tanstack/react-table';
|
||||
import { flexRender, type ColumnSizingState, type Row } from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import clsx from 'clsx';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import BasicSticky from 'react-sticky-el';
|
||||
import { useWindowEventListener } from 'rooks';
|
||||
import useResizeObserver from 'use-resize-observer';
|
||||
import { getItemFilePath, type ExplorerItem } from '@sd/client';
|
||||
import { ContextMenu, Tooltip } from '@sd/ui';
|
||||
import { useIsTextTruncated, useShortcut } from '~/hooks';
|
||||
import { type ExplorerItem } from '@sd/client';
|
||||
import { ContextMenu } from '@sd/ui';
|
||||
import { TruncatedText } from '~/components';
|
||||
import { useShortcut } from '~/hooks';
|
||||
import { isNonEmptyObject } from '~/util';
|
||||
|
||||
import { useLayoutContext } from '../../../Layout/Context';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { getQuickPreviewStore, useQuickPreviewStore } from '../../QuickPreview/store';
|
||||
import {
|
||||
createOrdering,
|
||||
getOrderingDirection,
|
||||
isCut,
|
||||
orderingKey,
|
||||
useExplorerStore
|
||||
} from '../../store';
|
||||
import { createOrdering, getOrderingDirection, orderingKey } from '../../store';
|
||||
import { uniqueId } from '../../util';
|
||||
import { useExplorerViewContext } from '../../ViewContext';
|
||||
import { useExplorerViewPadding } from '../util';
|
||||
import { ViewItem } from '../ViewItem';
|
||||
import { getRangeDirection, Range, useRanges } from './util/ranges';
|
||||
import { useTable } from './util/table';
|
||||
|
||||
interface ListViewItemProps {
|
||||
row: Row<ExplorerItem>;
|
||||
paddingLeft: number;
|
||||
paddingRight: number;
|
||||
// Props below are passed to trigger a rerender
|
||||
// TODO: Find a better solution
|
||||
columnSizing: ColumnSizingState;
|
||||
columnVisibility: VisibilityState;
|
||||
isCut: boolean;
|
||||
}
|
||||
|
||||
const ListViewItem = memo((props: ListViewItemProps) => {
|
||||
const filePathData = getItemFilePath(props.row.original);
|
||||
const hidden = filePathData?.hidden ?? false;
|
||||
|
||||
return (
|
||||
<ViewItem
|
||||
data={props.row.original}
|
||||
className="relative flex h-full items-center"
|
||||
style={{ paddingLeft: props.paddingLeft, paddingRight: props.paddingRight }}
|
||||
>
|
||||
{props.row.getVisibleCells().map((cell) => (
|
||||
<div
|
||||
role="cell"
|
||||
key={cell.id}
|
||||
className={clsx(
|
||||
'table-cell shrink-0 px-4 text-xs text-ink-dull',
|
||||
cell.column.id !== 'name' && 'truncate',
|
||||
cell.column.columnDef.meta?.className,
|
||||
hidden && 'opacity-50'
|
||||
)}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
))}
|
||||
</ViewItem>
|
||||
);
|
||||
});
|
||||
|
||||
const HeaderColumnName = ({ name }: { name: string }) => {
|
||||
const textRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
const isTruncated = useIsTextTruncated(textRef);
|
||||
|
||||
return (
|
||||
<div ref={textRef} className="truncate">
|
||||
{isTruncated ? (
|
||||
<Tooltip label={name}>
|
||||
<span className="truncate">{name}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>{name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { useExplorerViewContext } from '../Context';
|
||||
import { useDragScrollable } from '../useDragScrollable';
|
||||
import { TableContext } from './context';
|
||||
import { TableRow } from './TableRow';
|
||||
import { getRangeDirection, Range, useRanges } from './useRanges';
|
||||
import { useTable } from './useTable';
|
||||
|
||||
const ROW_HEIGHT = 45;
|
||||
const PADDING_X = 16;
|
||||
const PADDING_Y = 12;
|
||||
export const TABLE_PADDING_X = 16;
|
||||
export const TABLE_PADDING_Y = 12;
|
||||
|
||||
export default () => {
|
||||
export const ListView = memo(() => {
|
||||
const layout = useLayoutContext();
|
||||
const explorer = useExplorerContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
const explorerView = useExplorerViewContext();
|
||||
const settings = explorer.useSettingsSnapshot();
|
||||
const explorerSettings = explorer.useSettingsSnapshot();
|
||||
const quickPreview = useQuickPreviewStore();
|
||||
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const tableHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const tableHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { ref: scrollableRef } = useDragScrollable({ direction: 'up' });
|
||||
|
||||
const [sized, setSized] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
@ -125,23 +59,12 @@ export default () => {
|
|||
rows: rowsById
|
||||
});
|
||||
|
||||
const viewPadding = useExplorerViewPadding(explorerView.padding);
|
||||
|
||||
const padding = {
|
||||
top: viewPadding.top ?? PADDING_Y,
|
||||
bottom: viewPadding.bottom ?? PADDING_Y,
|
||||
left: viewPadding.left ?? PADDING_X,
|
||||
right: viewPadding.right ?? PADDING_X
|
||||
};
|
||||
|
||||
const count = !explorer.count ? rows.length : Math.max(rows.length, explorer.count);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: count,
|
||||
count: !explorer.count ? rows.length : Math.max(rows.length, explorer.count),
|
||||
getScrollElement: useCallback(() => explorer.scrollRef.current, [explorer.scrollRef]),
|
||||
estimateSize: useCallback(() => ROW_HEIGHT, []),
|
||||
paddingStart: padding.top,
|
||||
paddingEnd: padding.bottom + (explorerView.bottom ?? 0),
|
||||
paddingStart: TABLE_PADDING_Y,
|
||||
paddingEnd: TABLE_PADDING_Y + (explorerView.bottom ?? 0),
|
||||
scrollMargin: listOffset,
|
||||
overscan: explorer.overscan ?? 10
|
||||
});
|
||||
|
@ -424,7 +347,7 @@ export default () => {
|
|||
}
|
||||
};
|
||||
|
||||
function handleRowContextMenu(row: Row<ExplorerItem>) {
|
||||
const handleRowContextMenu = (row: Row<ExplorerItem>) => {
|
||||
if (explorerView.contextMenu === undefined) return;
|
||||
|
||||
const item = row.original;
|
||||
|
@ -434,7 +357,7 @@ export default () => {
|
|||
const hash = uniqueId(item);
|
||||
setRanges([[hash, hash]]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToRow = useCallback(
|
||||
(row: Row<ExplorerItem>) => {
|
||||
|
@ -457,14 +380,14 @@ export default () => {
|
|||
const rowBottom = rowTop + ROW_HEIGHT;
|
||||
|
||||
if (rowTop < tableTop) {
|
||||
const scrollBy = rowTop - tableTop - (row.index === 0 ? padding.top : 0);
|
||||
const scrollBy = rowTop - tableTop - (row.index === 0 ? TABLE_PADDING_Y : 0);
|
||||
explorer.scrollRef.current.scrollBy({ top: scrollBy });
|
||||
} else if (rowBottom > scrollRect.height - (explorerView.bottom ?? 0)) {
|
||||
const scrollBy =
|
||||
rowBottom -
|
||||
scrollRect.height +
|
||||
(explorerView.bottom ?? 0) +
|
||||
(row.index === rows.length - 1 ? padding.bottom : 0);
|
||||
(row.index === rows.length - 1 ? TABLE_PADDING_Y : 0);
|
||||
|
||||
explorer.scrollRef.current.scrollBy({ top: scrollBy });
|
||||
}
|
||||
|
@ -473,139 +396,12 @@ export default () => {
|
|||
explorer.scrollRef,
|
||||
explorerView.bottom,
|
||||
explorerView.top,
|
||||
padding.bottom,
|
||||
padding.top,
|
||||
rowVirtualizer.options.paddingStart,
|
||||
rows.length,
|
||||
top
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => setRanges([]), [settings.order]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getQuickPreviewStore().open || explorer.selectedItems.size !== 1) return;
|
||||
|
||||
const [item] = [...explorer.selectedItems];
|
||||
if (!item) return;
|
||||
|
||||
const itemId = uniqueId(item);
|
||||
setRanges([[itemId, itemId]]);
|
||||
}, [explorer.selectedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized || !sized || !explorer.count || explorer.selectedItems.size === 0) {
|
||||
if (explorer.selectedItems.size === 0 && !initialized) setInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [...explorer.selectedItems]
|
||||
.reduce((rows, item) => {
|
||||
const row = rowsById[uniqueId(item)];
|
||||
if (row) rows.push(row);
|
||||
return rows;
|
||||
}, [] as Row<ExplorerItem>[])
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
const lastRow = rows[rows.length - 1];
|
||||
if (!lastRow) return;
|
||||
|
||||
scrollToRow(lastRow);
|
||||
setRanges(rows.map((row) => [uniqueId(row.original), uniqueId(row.original)] as Range));
|
||||
setInitialized(true);
|
||||
}, [explorer.count, explorer.selectedItems, initialized, rowsById, scrollToRow, sized]);
|
||||
|
||||
// Measure initial column widths
|
||||
useEffect(() => {
|
||||
if (
|
||||
!tableRef.current ||
|
||||
sized ||
|
||||
!isNonEmptyObject(columnSizing) ||
|
||||
!isNonEmptyObject(columnVisibility)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizing = table
|
||||
.getVisibleLeafColumns()
|
||||
.reduce(
|
||||
(sizing, column) => ({ ...sizing, [column.id]: column.getSize() }),
|
||||
{} as ColumnSizingState
|
||||
);
|
||||
|
||||
const tableWidth = tableRef.current.offsetWidth;
|
||||
const columnsWidth =
|
||||
Object.values(sizing).reduce((a, b) => a + b, 0) + (padding.left + padding.right);
|
||||
|
||||
if (columnsWidth < tableWidth) {
|
||||
const nameWidth = (sizing.name ?? 0) + (tableWidth - columnsWidth);
|
||||
table.setColumnSizing({ ...sizing, name: nameWidth });
|
||||
setLocked(true);
|
||||
} else if (columnsWidth > tableWidth) {
|
||||
const nameColSize = sizing.name ?? 0;
|
||||
const minNameColSize = table.getColumn('name')?.columnDef.minSize;
|
||||
|
||||
const difference = columnsWidth - tableWidth;
|
||||
|
||||
if (minNameColSize !== undefined && nameColSize - difference >= minNameColSize) {
|
||||
table.setColumnSizing({ ...sizing, name: nameColSize - difference });
|
||||
setLocked(true);
|
||||
}
|
||||
} else if (columnsWidth === tableWidth) {
|
||||
setLocked(true);
|
||||
}
|
||||
|
||||
setSized(true);
|
||||
}, [columnSizing, columnVisibility, padding.left, padding.right, sized, table]);
|
||||
|
||||
// Load more items
|
||||
useEffect(() => {
|
||||
if (!explorer.loadMore) return;
|
||||
|
||||
const lastRow = virtualRows[virtualRows.length - 1];
|
||||
if (!lastRow) return;
|
||||
|
||||
const loadMoreFromRow = Math.ceil(rows.length * 0.75);
|
||||
|
||||
if (lastRow.index >= loadMoreFromRow - 1) explorer.loadMore.call(undefined);
|
||||
}, [virtualRows, rows.length, explorer.loadMore]);
|
||||
|
||||
// Sync scroll
|
||||
useEffect(() => {
|
||||
const table = tableRef.current;
|
||||
const header = tableHeaderRef.current;
|
||||
const body = tableBodyRef.current;
|
||||
|
||||
if (!table || !header || !body || quickPreview.open) return;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return;
|
||||
event.preventDefault();
|
||||
header.scrollLeft += event.deltaX;
|
||||
body.scrollLeft += event.deltaX;
|
||||
};
|
||||
|
||||
const handleScroll = (element: HTMLDivElement) => {
|
||||
if (isLeftMouseDown) return;
|
||||
// Sorting sometimes resets scrollLeft
|
||||
// so we reset it here in case it does
|
||||
// to keep the scroll in sync
|
||||
// TODO: Find a better solution
|
||||
header.scrollLeft = element.scrollLeft;
|
||||
body.scrollLeft = element.scrollLeft;
|
||||
};
|
||||
|
||||
table.addEventListener('wheel', handleWheel);
|
||||
header.addEventListener('scroll', () => handleScroll(header));
|
||||
body.addEventListener('scroll', () => handleScroll(body));
|
||||
|
||||
return () => {
|
||||
table.removeEventListener('wheel', handleWheel);
|
||||
header.addEventListener('scroll', () => handleScroll(header));
|
||||
body.addEventListener('scroll', () => handleScroll(body));
|
||||
};
|
||||
}, [sized, isLeftMouseDown]);
|
||||
|
||||
const keyboardHandler = (e: KeyboardEvent, direction: 'ArrowDown' | 'ArrowUp') => {
|
||||
if (!explorerView.selectable) return;
|
||||
|
||||
|
@ -760,6 +556,130 @@ export default () => {
|
|||
scrollToRow(nextRow);
|
||||
};
|
||||
|
||||
useEffect(() => setRanges([]), [explorerSettings.order]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getQuickPreviewStore().open || explorer.selectedItems.size !== 1) return;
|
||||
|
||||
const [item] = [...explorer.selectedItems];
|
||||
if (!item) return;
|
||||
|
||||
const itemId = uniqueId(item);
|
||||
setRanges([[itemId, itemId]]);
|
||||
}, [explorer.selectedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized || !sized || !explorer.count || explorer.selectedItems.size === 0) {
|
||||
if (explorer.selectedItems.size === 0 && !initialized) setInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [...explorer.selectedItems]
|
||||
.reduce((rows, item) => {
|
||||
const row = rowsById[uniqueId(item)];
|
||||
if (row) rows.push(row);
|
||||
return rows;
|
||||
}, [] as Row<ExplorerItem>[])
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
const lastRow = rows[rows.length - 1];
|
||||
if (!lastRow) return;
|
||||
|
||||
scrollToRow(lastRow);
|
||||
setRanges(rows.map((row) => [uniqueId(row.original), uniqueId(row.original)] as Range));
|
||||
setInitialized(true);
|
||||
}, [explorer.count, explorer.selectedItems, initialized, rowsById, scrollToRow, sized]);
|
||||
|
||||
// Measure initial column widths
|
||||
useEffect(() => {
|
||||
if (
|
||||
!tableRef.current ||
|
||||
sized ||
|
||||
!isNonEmptyObject(columnSizing) ||
|
||||
!isNonEmptyObject(columnVisibility)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizing = table
|
||||
.getVisibleLeafColumns()
|
||||
.reduce(
|
||||
(sizing, column) => ({ ...sizing, [column.id]: column.getSize() }),
|
||||
{} as ColumnSizingState
|
||||
);
|
||||
|
||||
const tableWidth = tableRef.current.offsetWidth;
|
||||
const columnsWidth = Object.values(sizing).reduce((a, b) => a + b, 0) + TABLE_PADDING_X * 2;
|
||||
|
||||
if (columnsWidth < tableWidth) {
|
||||
const nameWidth = (sizing.name ?? 0) + (tableWidth - columnsWidth);
|
||||
table.setColumnSizing({ ...sizing, name: nameWidth });
|
||||
setLocked(true);
|
||||
} else if (columnsWidth > tableWidth) {
|
||||
const nameColSize = sizing.name ?? 0;
|
||||
const minNameColSize = table.getColumn('name')?.columnDef.minSize;
|
||||
|
||||
const difference = columnsWidth - tableWidth;
|
||||
|
||||
if (minNameColSize !== undefined && nameColSize - difference >= minNameColSize) {
|
||||
table.setColumnSizing({ ...sizing, name: nameColSize - difference });
|
||||
setLocked(true);
|
||||
}
|
||||
} else if (columnsWidth === tableWidth) {
|
||||
setLocked(true);
|
||||
}
|
||||
|
||||
setSized(true);
|
||||
}, [columnSizing, columnVisibility, sized, table]);
|
||||
|
||||
// Load more items
|
||||
useEffect(() => {
|
||||
if (!explorer.loadMore) return;
|
||||
|
||||
const lastRow = virtualRows[virtualRows.length - 1];
|
||||
if (!lastRow) return;
|
||||
|
||||
const loadMoreFromRow = Math.ceil(rows.length * 0.75);
|
||||
|
||||
if (lastRow.index >= loadMoreFromRow - 1) explorer.loadMore.call(undefined);
|
||||
}, [virtualRows, rows.length, explorer.loadMore]);
|
||||
|
||||
// Sync scroll
|
||||
useEffect(() => {
|
||||
const table = tableRef.current;
|
||||
const header = tableHeaderRef.current;
|
||||
const body = tableBodyRef.current;
|
||||
|
||||
if (!table || !header || !body || quickPreview.open) return;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return;
|
||||
event.preventDefault();
|
||||
header.scrollLeft += event.deltaX;
|
||||
body.scrollLeft += event.deltaX;
|
||||
};
|
||||
|
||||
const handleScroll = (element: HTMLDivElement) => {
|
||||
if (isLeftMouseDown) return;
|
||||
// Sorting sometimes resets scrollLeft
|
||||
// so we reset it here in case it does
|
||||
// to keep the scroll in sync
|
||||
// TODO: Find a better solution
|
||||
header.scrollLeft = element.scrollLeft;
|
||||
body.scrollLeft = element.scrollLeft;
|
||||
};
|
||||
|
||||
table.addEventListener('wheel', handleWheel);
|
||||
header.addEventListener('scroll', () => handleScroll(header));
|
||||
body.addEventListener('scroll', () => handleScroll(body));
|
||||
|
||||
return () => {
|
||||
table.removeEventListener('wheel', handleWheel);
|
||||
header.addEventListener('scroll', () => handleScroll(header));
|
||||
body.addEventListener('scroll', () => handleScroll(body));
|
||||
};
|
||||
}, [sized, isLeftMouseDown, quickPreview.open]);
|
||||
|
||||
useShortcut('explorerEscape', () => {
|
||||
if (!explorerView.selectable || explorer.selectedItems.size === 0) return;
|
||||
explorer.resetSelectedItems([]);
|
||||
|
@ -799,7 +719,7 @@ export default () => {
|
|||
);
|
||||
|
||||
const columnsWidth =
|
||||
Object.values(sizing).reduce((a, b) => a + b, 0) + (padding.left + padding.right);
|
||||
Object.values(sizing).reduce((a, b) => a + b, 0) + TABLE_PADDING_X * 2;
|
||||
|
||||
if (locked) {
|
||||
const newNameSize = (sizing.name ?? 0) + (width - columnsWidth);
|
||||
|
@ -843,263 +763,237 @@ export default () => {
|
|||
useLayoutEffect(() => setListOffset(tableRef.current?.offsetTop ?? 0), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tableRef}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
e.stopPropagation();
|
||||
setIsLeftMouseDown(true);
|
||||
}}
|
||||
className={clsx(!initialized && 'invisible')}
|
||||
>
|
||||
{sized && (
|
||||
<>
|
||||
<BasicSticky
|
||||
scrollElement={explorer.scrollRef.current ?? undefined}
|
||||
stickyStyle={{ top, zIndex: 10 }}
|
||||
topOffset={-top}
|
||||
// Without this the width of the element doesn't get updated
|
||||
// when the inspector is toggled
|
||||
positionRecheckInterval={100}
|
||||
>
|
||||
<ContextMenu.Root
|
||||
trigger={
|
||||
<div
|
||||
ref={tableHeaderRef}
|
||||
className={clsx(
|
||||
'top-bar-blur !border-sidebar-divider bg-app/90',
|
||||
explorerView.listViewOptions?.hideHeaderBorder
|
||||
? 'border-b'
|
||||
: 'border-y',
|
||||
// Prevent drag scroll
|
||||
isLeftMouseDown
|
||||
? 'overflow-hidden'
|
||||
: 'no-scrollbar overflow-x-auto overscroll-x-none'
|
||||
)}
|
||||
>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<div key={headerGroup.id} className="flex w-fit">
|
||||
{headerGroup.headers.map((header, i) => {
|
||||
const size = header.column.getSize();
|
||||
|
||||
const orderKey =
|
||||
settings.order && orderingKey(settings.order);
|
||||
|
||||
const orderingDirection =
|
||||
orderKey &&
|
||||
settings.order &&
|
||||
(orderKey.startsWith('object.')
|
||||
? orderKey.split('object.')[1] === header.id
|
||||
: orderKey === header.id)
|
||||
? getOrderingDirection(settings.order)
|
||||
: null;
|
||||
|
||||
const cellContent = flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={clsx(
|
||||
'relative flex items-center justify-between gap-3 px-4 py-2 text-xs first:pl-[83px]',
|
||||
orderingDirection !== null
|
||||
? 'text-ink'
|
||||
: 'text-ink-dull'
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
i === 0 ||
|
||||
i === headerGroup.headers.length - 1
|
||||
? size +
|
||||
padding[i ? 'right' : 'left']
|
||||
: size
|
||||
}}
|
||||
onClick={() => {
|
||||
if (resizing) return;
|
||||
|
||||
// Split table into smaller parts
|
||||
// cause this looks hideous
|
||||
const orderKey =
|
||||
explorer.orderingKeys?.options.find(
|
||||
(o) => {
|
||||
if (
|
||||
typeof o.value !==
|
||||
'string'
|
||||
)
|
||||
return;
|
||||
|
||||
const value =
|
||||
o.value as string;
|
||||
|
||||
return value.startsWith(
|
||||
'object.'
|
||||
)
|
||||
? value.split(
|
||||
'object.'
|
||||
)[1] === header.id
|
||||
: value === header.id;
|
||||
}
|
||||
);
|
||||
|
||||
if (!orderKey) return;
|
||||
|
||||
explorer.settingsStore.order =
|
||||
createOrdering(
|
||||
orderKey.value,
|
||||
orderingDirection === 'Asc'
|
||||
? 'Desc'
|
||||
: 'Asc'
|
||||
);
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<>
|
||||
{typeof cellContent === 'string' ? (
|
||||
<HeaderColumnName
|
||||
name={cellContent}
|
||||
/>
|
||||
) : (
|
||||
cellContent
|
||||
)}
|
||||
|
||||
{orderingDirection === 'Asc' && (
|
||||
<CaretUp className="shrink-0 text-ink-faint" />
|
||||
)}
|
||||
|
||||
{orderingDirection === 'Desc' && (
|
||||
<CaretDown className="shrink-0 text-ink-faint" />
|
||||
)}
|
||||
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
setResizing(true);
|
||||
setLocked(false);
|
||||
|
||||
header.getResizeHandler()(
|
||||
e
|
||||
);
|
||||
|
||||
if (layout.ref.current) {
|
||||
layout.ref.current.style.cursor =
|
||||
'col-resize';
|
||||
}
|
||||
}}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className="absolute right-0 h-[70%] w-2 cursor-col-resize border-r border-sidebar-divider"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
<TableContext.Provider value={{ columnSizing }}>
|
||||
<div
|
||||
ref={tableRef}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
setIsLeftMouseDown(true);
|
||||
}}
|
||||
className={clsx(!initialized && 'invisible')}
|
||||
>
|
||||
{sized && (
|
||||
<>
|
||||
<BasicSticky
|
||||
scrollElement={explorer.scrollRef.current ?? undefined}
|
||||
stickyStyle={{ top, zIndex: 10 }}
|
||||
topOffset={-top}
|
||||
// Without this the width of the element doesn't get updated
|
||||
// when the inspector is toggled
|
||||
positionRecheckInterval={100}
|
||||
>
|
||||
{table.getAllLeafColumns().map((column) => {
|
||||
if (column.id === 'name') return null;
|
||||
return (
|
||||
<ContextMenu.CheckboxItem
|
||||
key={column.id}
|
||||
checked={column.getIsVisible()}
|
||||
onSelect={column.getToggleVisibilityHandler()}
|
||||
label={
|
||||
typeof column.columnDef.header === 'string'
|
||||
? column.columnDef.header
|
||||
: column.id
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ContextMenu.Root>
|
||||
</BasicSticky>
|
||||
|
||||
<div
|
||||
ref={tableBodyRef}
|
||||
className={clsx(
|
||||
// Prevent drag scroll
|
||||
isLeftMouseDown
|
||||
? 'overflow-hidden'
|
||||
: 'no-scrollbar overflow-x-auto overscroll-x-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
const selected = explorer.isItemSelected(row.original);
|
||||
const cut = isCut(row.original, explorerStore.cutCopyState);
|
||||
|
||||
const previousRow = rows[virtualRow.index - 1];
|
||||
const nextRow = rows[virtualRow.index + 1];
|
||||
|
||||
const selectedPrior =
|
||||
previousRow && explorer.isItemSelected(previousRow.original);
|
||||
|
||||
const selectedNext =
|
||||
nextRow && explorer.isItemSelected(nextRow.original);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root
|
||||
trigger={
|
||||
<div
|
||||
key={row.id}
|
||||
className="absolute left-0 top-0 min-w-full"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
transform: `translateY(${
|
||||
virtualRow.start -
|
||||
rowVirtualizer.options.scrollMargin
|
||||
}px)`
|
||||
ref={(element) => {
|
||||
tableHeaderRef.current = element;
|
||||
scrollableRef(element);
|
||||
}}
|
||||
onMouseDown={(e) => handleRowClick(e, row)}
|
||||
onContextMenu={() => handleRowContextMenu(row)}
|
||||
className={clsx(
|
||||
'top-bar-blur !border-sidebar-divider bg-app/90',
|
||||
explorerView.listViewOptions?.hideHeaderBorder
|
||||
? 'border-b'
|
||||
: 'border-y',
|
||||
// Prevent drag scroll
|
||||
isLeftMouseDown
|
||||
? 'overflow-hidden'
|
||||
: 'no-scrollbar overflow-x-auto overscroll-x-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-0 rounded-md border',
|
||||
virtualRow.index % 2 === 0 && 'bg-app-darkBox',
|
||||
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'
|
||||
)}
|
||||
style={{
|
||||
right: padding.right,
|
||||
left: padding.left
|
||||
}}
|
||||
>
|
||||
{selectedPrior && (
|
||||
<div className="absolute inset-x-3 top-0 h-px bg-accent/10" />
|
||||
)}
|
||||
</div>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<div key={headerGroup.id} className="flex w-fit">
|
||||
{headerGroup.headers.map((header, i) => {
|
||||
const size = header.column.getSize();
|
||||
|
||||
<ListViewItem
|
||||
row={row}
|
||||
paddingLeft={padding.left}
|
||||
paddingRight={padding.right}
|
||||
columnSizing={columnSizing}
|
||||
columnVisibility={columnVisibility}
|
||||
isCut={cut}
|
||||
/>
|
||||
const orderKey =
|
||||
explorerSettings.order &&
|
||||
orderingKey(explorerSettings.order);
|
||||
|
||||
const orderingDirection =
|
||||
orderKey &&
|
||||
explorerSettings.order &&
|
||||
(orderKey.startsWith('object.')
|
||||
? orderKey.split('object.')[1] ===
|
||||
header.id
|
||||
: orderKey === header.id)
|
||||
? getOrderingDirection(
|
||||
explorerSettings.order
|
||||
)
|
||||
: null;
|
||||
|
||||
const cellContent = flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={clsx(
|
||||
'relative flex items-center justify-between gap-3 px-4 py-2 text-xs first:pl-[83px]',
|
||||
orderingDirection !== null
|
||||
? 'text-ink'
|
||||
: 'text-ink-dull'
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
i === 0 ||
|
||||
i ===
|
||||
headerGroup.headers.length -
|
||||
1
|
||||
? size + TABLE_PADDING_X
|
||||
: size
|
||||
}}
|
||||
onClick={() => {
|
||||
if (resizing) return;
|
||||
|
||||
// Split table into smaller parts
|
||||
// cause this looks hideous
|
||||
const orderKey =
|
||||
explorer.orderingKeys?.options.find(
|
||||
(o) => {
|
||||
if (
|
||||
typeof o.value !==
|
||||
'string'
|
||||
)
|
||||
return;
|
||||
|
||||
const value =
|
||||
o.value as string;
|
||||
|
||||
return value.startsWith(
|
||||
'object.'
|
||||
)
|
||||
? value.split(
|
||||
'object.'
|
||||
)[1] === header.id
|
||||
: value ===
|
||||
header.id;
|
||||
}
|
||||
);
|
||||
|
||||
if (!orderKey) return;
|
||||
|
||||
explorer.settingsStore.order =
|
||||
createOrdering(
|
||||
orderKey.value,
|
||||
orderingDirection === 'Asc'
|
||||
? 'Desc'
|
||||
: 'Asc'
|
||||
);
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<>
|
||||
<TruncatedText>
|
||||
{cellContent}
|
||||
</TruncatedText>
|
||||
|
||||
{orderingDirection ===
|
||||
'Asc' && (
|
||||
<CaretUp className="shrink-0 text-ink-faint" />
|
||||
)}
|
||||
|
||||
{orderingDirection ===
|
||||
'Desc' && (
|
||||
<CaretDown className="shrink-0 text-ink-faint" />
|
||||
)}
|
||||
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
setResizing(true);
|
||||
setLocked(false);
|
||||
|
||||
header.getResizeHandler()(
|
||||
e
|
||||
);
|
||||
|
||||
if (
|
||||
layout.ref.current
|
||||
) {
|
||||
layout.ref.current.style.cursor =
|
||||
'col-resize';
|
||||
}
|
||||
}}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className="absolute right-0 h-[70%] w-2 cursor-col-resize border-r border-sidebar-divider"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}
|
||||
>
|
||||
{table.getAllLeafColumns().map((column) => {
|
||||
if (column.id === 'name') return null;
|
||||
return (
|
||||
<ContextMenu.CheckboxItem
|
||||
key={column.id}
|
||||
checked={column.getIsVisible()}
|
||||
onSelect={column.getToggleVisibilityHandler()}
|
||||
label={
|
||||
typeof column.columnDef.header === 'string'
|
||||
? column.columnDef.header
|
||||
: column.id
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ContextMenu.Root>
|
||||
</BasicSticky>
|
||||
|
||||
<div
|
||||
ref={tableBodyRef}
|
||||
className={clsx(
|
||||
// Prevent drag scroll
|
||||
isLeftMouseDown
|
||||
? 'overflow-hidden'
|
||||
: 'no-scrollbar overflow-x-auto overscroll-x-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
const previousRow = rows[virtualRow.index - 1];
|
||||
const nextRow = rows[virtualRow.index + 1];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className="absolute left-0 top-0 min-w-full"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
transform: `translateY(${
|
||||
virtualRow.start -
|
||||
rowVirtualizer.options.scrollMargin
|
||||
}px)`
|
||||
}}
|
||||
onMouseDown={(e) => handleRowClick(e, row)}
|
||||
onContextMenu={() => handleRowContextMenu(row)}
|
||||
>
|
||||
<TableRow
|
||||
row={row}
|
||||
previousRow={previousRow}
|
||||
nextRow={nextRow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableContext.Provider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
CellContext,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
|
@ -7,7 +8,7 @@ import {
|
|||
} from '@tanstack/react-table';
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { stringify } from 'uuid';
|
||||
import {
|
||||
byteSize,
|
||||
|
@ -19,16 +20,44 @@ import {
|
|||
} from '@sd/client';
|
||||
import { isNonEmptyObject } from '~/util';
|
||||
|
||||
import { useExplorerContext } from '../../../Context';
|
||||
import { FileThumb } from '../../../FilePath/Thumb';
|
||||
import { InfoPill } from '../../../Inspector';
|
||||
import { isCut, useExplorerStore } from '../../../store';
|
||||
import { uniqueId } from '../../../util';
|
||||
import { RenamableItemText } from '../../RenamableItemText';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { FileThumb } from '../../FilePath/Thumb';
|
||||
import { InfoPill } from '../../Inspector';
|
||||
import { CutCopyState, isCut, useExplorerStore } from '../../store';
|
||||
import { uniqueId } from '../../util';
|
||||
import { RenamableItemText } from '../RenamableItemText';
|
||||
|
||||
const NameCell = memo(({ item, selected }: { item: ExplorerItem; selected: boolean }) => {
|
||||
const { cutCopyState } = useExplorerStore();
|
||||
|
||||
const cut = useMemo(() => isCut(item, cutCopyState as CutCopyState), [cutCopyState, item]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<FileThumb
|
||||
data={item}
|
||||
frame
|
||||
frameClassName="!border"
|
||||
blackBars
|
||||
size={35}
|
||||
className={clsx('mr-2.5', cut && 'opacity-60')}
|
||||
/>
|
||||
|
||||
<RenamableItemText
|
||||
item={item}
|
||||
selected={selected}
|
||||
allowHighlight={false}
|
||||
style={{ maxHeight: 36 }}
|
||||
idleClassName="w-full !max-h-5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type Cell = CellContext<ExplorerItem, unknown> & { selected?: boolean };
|
||||
|
||||
export const useTable = () => {
|
||||
const explorer = useExplorerContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
@ -40,29 +69,9 @@ export const useTable = () => {
|
|||
header: 'Name',
|
||||
minSize: 200,
|
||||
maxSize: undefined,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
const cut = isCut(item, explorerStore.cutCopyState);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<FileThumb
|
||||
data={item}
|
||||
frame
|
||||
frameClassName="!border"
|
||||
blackBars
|
||||
size={35}
|
||||
className={clsx('mr-2.5', cut && 'opacity-60')}
|
||||
/>
|
||||
|
||||
<RenamableItemText
|
||||
item={item}
|
||||
allowHighlight={false}
|
||||
style={{ maxHeight: 36 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
cell: ({ row, selected }: Cell) => (
|
||||
<NameCell item={row.original} selected={!!selected} />
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'kind',
|
||||
|
@ -132,7 +141,7 @@ export const useTable = () => {
|
|||
}
|
||||
}
|
||||
],
|
||||
[explorerStore.cutCopyState]
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
|
@ -1,67 +0,0 @@
|
|||
import { ArrowsOutSimple } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { memo } from 'react';
|
||||
import { ExplorerItem, getItemFilePath } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { getQuickPreviewStore } from '../QuickPreview/store';
|
||||
import GridList from './GridList';
|
||||
import { ViewItem } from './ViewItem';
|
||||
|
||||
interface MediaViewItemProps {
|
||||
data: ExplorerItem;
|
||||
selected: boolean;
|
||||
cut: boolean;
|
||||
}
|
||||
|
||||
const MediaViewItem = memo(({ data, selected, cut }: MediaViewItemProps) => {
|
||||
const settings = useExplorerContext().useSettingsSnapshot();
|
||||
const filePathData = getItemFilePath(data);
|
||||
const hidden = filePathData?.hidden ?? false;
|
||||
|
||||
return (
|
||||
<ViewItem
|
||||
data={data}
|
||||
className={clsx(
|
||||
'h-full w-full overflow-hidden border-2',
|
||||
selected ? 'border-accent' : 'border-transparent',
|
||||
hidden && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'group relative flex aspect-square items-center justify-center hover:bg-app-selectedItem',
|
||||
selected && 'bg-app-selectedItem'
|
||||
)}
|
||||
>
|
||||
<FileThumb
|
||||
data={data}
|
||||
cover={settings.mediaAspectSquare}
|
||||
extension
|
||||
className={clsx(!settings.mediaAspectSquare && 'px-1', cut && 'opacity-60')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="gray"
|
||||
size="icon"
|
||||
className="absolute right-2 top-2 hidden rounded-full shadow group-hover:block"
|
||||
onClick={() => (getQuickPreviewStore().open = true)}
|
||||
>
|
||||
<ArrowsOutSimple />
|
||||
</Button>
|
||||
</div>
|
||||
</ViewItem>
|
||||
);
|
||||
});
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<GridList>
|
||||
{({ item, selected, cut }) => (
|
||||
<MediaViewItem data={item} selected={selected} cut={cut} />
|
||||
)}
|
||||
</GridList>
|
||||
);
|
||||
};
|
67
interface/app/$libraryId/Explorer/View/MediaView/Item.tsx
Normal file
67
interface/app/$libraryId/Explorer/View/MediaView/Item.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { ArrowsOutSimple } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { memo } from 'react';
|
||||
import { ExplorerItem, getItemFilePath } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
|
||||
import { FileThumb } from '../../FilePath/Thumb';
|
||||
import { getQuickPreviewStore } from '../../QuickPreview/store';
|
||||
import { useExplorerDraggable } from '../../useExplorerDraggable';
|
||||
import { ViewItem } from '../ViewItem';
|
||||
|
||||
interface Props {
|
||||
data: ExplorerItem;
|
||||
selected: boolean;
|
||||
cut: boolean;
|
||||
cover: boolean;
|
||||
}
|
||||
|
||||
export const MediaViewItem = memo(({ data, selected, cut, cover }: Props) => {
|
||||
return (
|
||||
<ViewItem
|
||||
data={data}
|
||||
className={clsx(
|
||||
'group relative h-full w-full border-2 hover:bg-app-selectedItem',
|
||||
selected ? 'border-accent bg-app-selectedItem' : 'border-transparent'
|
||||
)}
|
||||
>
|
||||
<ItemFileThumb data={data} cut={cut} cover={cover} />
|
||||
|
||||
<Button
|
||||
variant="gray"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 hidden !rounded shadow group-hover:block"
|
||||
onClick={() => (getQuickPreviewStore().open = true)}
|
||||
>
|
||||
<ArrowsOutSimple />
|
||||
</Button>
|
||||
</ViewItem>
|
||||
);
|
||||
});
|
||||
|
||||
const ItemFileThumb = (props: Pick<Props, 'data' | 'cut' | 'cover'>) => {
|
||||
const filePath = getItemFilePath(props.data);
|
||||
|
||||
const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable({
|
||||
data: props.data
|
||||
});
|
||||
|
||||
return (
|
||||
<FileThumb
|
||||
data={props.data}
|
||||
cover={props.cover}
|
||||
extension
|
||||
className={clsx(
|
||||
!props.cover && 'p-0.5',
|
||||
props.cut && 'opacity-60',
|
||||
filePath?.hidden && 'opacity-50'
|
||||
)}
|
||||
ref={setDraggableRef}
|
||||
childProps={{
|
||||
style,
|
||||
...attributes,
|
||||
...listeners
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
20
interface/app/$libraryId/Explorer/View/MediaView/index.tsx
Normal file
20
interface/app/$libraryId/Explorer/View/MediaView/index.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { useExplorerContext } from '../../Context';
|
||||
import Grid from '../Grid';
|
||||
import { MediaViewItem } from './Item';
|
||||
|
||||
export const MediaView = () => {
|
||||
const explorerSettings = useExplorerContext().useSettingsSnapshot();
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
{({ item, selected, cut }) => (
|
||||
<MediaViewItem
|
||||
data={item}
|
||||
selected={selected}
|
||||
cut={cut}
|
||||
cover={explorerSettings.mediaAspectSquare}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from 'clsx';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
getEphemeralPath,
|
||||
getExplorerItemData,
|
||||
|
@ -12,31 +12,31 @@ import { toast } from '@sd/ui';
|
|||
import { useIsDark } from '~/hooks';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { RenameTextBox } from '../FilePath/RenameTextBox';
|
||||
import { RenameTextBox, RenameTextBoxProps } from '../FilePath/RenameTextBox';
|
||||
import { useQuickPreviewStore } from '../QuickPreview/store';
|
||||
import { useExplorerStore } from '../store';
|
||||
|
||||
interface Props {
|
||||
interface Props extends Pick<RenameTextBoxProps, 'idleClassName' | 'lines'> {
|
||||
item: ExplorerItem;
|
||||
allowHighlight?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
lines?: number;
|
||||
highlight?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export const RenamableItemText = ({ item, allowHighlight = true, style, lines }: Props) => {
|
||||
const rspc = useRspcLibraryContext();
|
||||
const explorer = useExplorerContext();
|
||||
const quickPreviewStore = useQuickPreviewStore();
|
||||
export const RenamableItemText = ({ allowHighlight = true, ...props }: Props) => {
|
||||
const isDark = useIsDark();
|
||||
const rspc = useRspcLibraryContext();
|
||||
|
||||
const itemData = getExplorerItemData(item);
|
||||
const explorer = useExplorerContext({ suspense: false });
|
||||
const explorerStore = useExplorerStore();
|
||||
|
||||
const quickPreviewStore = useQuickPreviewStore();
|
||||
|
||||
const itemData = getExplorerItemData(props.item);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => explorer.selectedItems.has(item),
|
||||
[explorer.selectedItems, item]
|
||||
);
|
||||
|
||||
const renameFile = useLibraryMutation(['files.renameFile'], {
|
||||
onError: () => reset(),
|
||||
onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
|
||||
|
@ -59,9 +59,9 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
|
|||
|
||||
const handleRename = async (newName: string) => {
|
||||
try {
|
||||
switch (item.type) {
|
||||
switch (props.item.type) {
|
||||
case 'Location': {
|
||||
const locationId = item.item.id;
|
||||
const locationId = props.item.item.id;
|
||||
if (!locationId) throw new Error('Missing location id');
|
||||
|
||||
await renameLocation.mutateAsync({
|
||||
|
@ -79,7 +79,7 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
|
|||
|
||||
case 'Path':
|
||||
case 'Object': {
|
||||
const filePathData = getIndexedItemFilePath(item);
|
||||
const filePathData = getIndexedItemFilePath(props.item);
|
||||
|
||||
if (!filePathData) throw new Error('Failed to get file path object');
|
||||
|
||||
|
@ -101,7 +101,7 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
|
|||
}
|
||||
|
||||
case 'NonIndexedPath': {
|
||||
const ephemeralFile = getEphemeralPath(item);
|
||||
const ephemeralFile = getEphemeralPath(props.item);
|
||||
|
||||
if (!ephemeralFile) throw new Error('Failed to get ephemeral file object');
|
||||
|
||||
|
@ -130,10 +130,12 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
|
|||
};
|
||||
|
||||
const disabled =
|
||||
!selected ||
|
||||
!props.selected ||
|
||||
explorerStore.drag?.type === 'dragging' ||
|
||||
!explorer ||
|
||||
explorer.selectedItems.size > 1 ||
|
||||
quickPreviewStore.open ||
|
||||
item.type === 'SpacedropPeer';
|
||||
props.item.type === 'SpacedropPeer';
|
||||
|
||||
return (
|
||||
<RenameTextBox
|
||||
|
@ -141,11 +143,13 @@ export const RenamableItemText = ({ item, allowHighlight = true, style, lines }:
|
|||
disabled={disabled}
|
||||
onRename={handleRename}
|
||||
className={clsx(
|
||||
'text-center font-medium',
|
||||
selected && allowHighlight && ['bg-accent', !isDark && 'text-white']
|
||||
'font-medium',
|
||||
(props.selected || props.highlight) &&
|
||||
allowHighlight && ['bg-accent', !isDark && 'text-white']
|
||||
)}
|
||||
style={style}
|
||||
lines={lines}
|
||||
style={props.style}
|
||||
lines={props.lines}
|
||||
idleClassName={props.idleClassName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useCallback, type HTMLAttributes, type PropsWithChildren } from 'react';
|
||||
import { useCallback, type HTMLAttributes, type PropsWithChildren } from 'react';
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
isPath,
|
||||
|
@ -15,8 +15,9 @@ import { usePlatform } from '~/util/Platform';
|
|||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { getQuickPreviewStore } from '../QuickPreview/store';
|
||||
import { getExplorerStore } from '../store';
|
||||
import { uniqueId } from '../util';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import { useExplorerViewContext } from './Context';
|
||||
|
||||
export const useViewItemDoubleClick = () => {
|
||||
const navigate = useNavigate();
|
||||
|
@ -58,7 +59,7 @@ export const useViewItemDoubleClick = () => {
|
|||
: selectedItem.item.file_paths;
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (isPath(selectedItem) && selectedItem.item.is_dir) {
|
||||
if (filePath.is_dir) {
|
||||
items.dirs.splice(sameAsClicked ? 0 : -1, 0, filePath);
|
||||
} else {
|
||||
items.paths.splice(sameAsClicked ? 0 : -1, 0, filePath);
|
||||
|
@ -176,7 +177,7 @@ interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement
|
|||
data: ExplorerItem;
|
||||
}
|
||||
|
||||
export const ViewItem = memo(({ data, children, ...props }: ViewItemProps) => {
|
||||
export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
const { doubleClick } = useViewItemDoubleClick();
|
||||
|
@ -184,15 +185,15 @@ export const ViewItem = memo(({ data, children, ...props }: ViewItemProps) => {
|
|||
return (
|
||||
<ContextMenu.Root
|
||||
trigger={
|
||||
<div onDoubleClick={() => doubleClick(data)} {...props}>
|
||||
<div {...props} onDoubleClick={() => doubleClick(data)}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
onOpenChange={explorerView.setIsContextMenuOpen}
|
||||
onOpenChange={(open) => (getExplorerStore().isContextMenuOpen = open)}
|
||||
disabled={explorerView.contextMenu === undefined}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{explorerView.contextMenu}
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,25 +1,10 @@
|
|||
import { Columns, GridFour, MonitorPlay, Rows, type Icon } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
isValidElement,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
ExplorerLayout,
|
||||
getExplorerLayoutStore,
|
||||
getItemObject,
|
||||
useExplorerLayoutStore,
|
||||
type Object
|
||||
} from '@sd/client';
|
||||
import { dialogManager, ModifierKeys } from '@sd/ui';
|
||||
import { useKeys } from 'rooks';
|
||||
import { ExplorerLayout, getExplorerLayoutStore, getItemObject, type Object } from '@sd/client';
|
||||
import { dialogManager } from '@sd/ui';
|
||||
import { Loader } from '~/components';
|
||||
import { useKeyCopyCutPaste, useOperatingSystem, useShortcut } from '~/hooks';
|
||||
import { useKeyCopyCutPaste, useKeyMatcher, useShortcut } from '~/hooks';
|
||||
import { isNonEmpty } from '~/util';
|
||||
|
||||
import CreateDialog from '../../settings/library/tags/CreateDialog';
|
||||
|
@ -27,226 +12,196 @@ import { useExplorerContext } from '../Context';
|
|||
import { QuickPreview } from '../QuickPreview';
|
||||
import { useQuickPreviewContext } from '../QuickPreview/Context';
|
||||
import { getQuickPreviewStore, useQuickPreviewStore } from '../QuickPreview/store';
|
||||
import { ViewContext, type ExplorerViewContext } from '../ViewContext';
|
||||
import GridView from './GridView';
|
||||
import ListView from './ListView';
|
||||
import MediaView from './MediaView';
|
||||
import { useExplorerViewPadding } from './util';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
import { useExplorerDroppable } from '../useExplorerDroppable';
|
||||
import { useExplorerSearchParams } from '../util';
|
||||
import { ViewContext, type ExplorerViewContext } from './Context';
|
||||
import { DragScrollable } from './DragScrollable';
|
||||
import { GridView } from './GridView';
|
||||
import { ListView } from './ListView';
|
||||
import { MediaView } from './MediaView';
|
||||
import { useViewItemDoubleClick } from './ViewItem';
|
||||
|
||||
export interface ExplorerViewPadding {
|
||||
x?: number;
|
||||
y?: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
}
|
||||
|
||||
export interface ExplorerViewProps
|
||||
extends Omit<
|
||||
ExplorerViewContext,
|
||||
| 'selectable'
|
||||
| 'isRenaming'
|
||||
| 'isContextMenuOpen'
|
||||
| 'setIsRenaming'
|
||||
| 'setIsContextMenuOpen'
|
||||
| 'ref'
|
||||
| 'padding'
|
||||
> {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
extends Omit<ExplorerViewContext, 'selectable' | 'ref' | 'padding'> {
|
||||
emptyNotice?: JSX.Element;
|
||||
padding?: number | ExplorerViewPadding;
|
||||
}
|
||||
|
||||
export default memo(
|
||||
({ className, style, emptyNotice, padding, ...contextProps }: ExplorerViewProps) => {
|
||||
const explorer = useExplorerContext();
|
||||
const quickPreview = useQuickPreviewContext();
|
||||
const quickPreviewStore = useQuickPreviewStore();
|
||||
const layoutStore = useExplorerLayoutStore();
|
||||
const { doubleClick } = useViewItemDoubleClick();
|
||||
export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
|
||||
const explorer = useExplorerContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
const { layoutMode } = explorer.useSettingsSnapshot();
|
||||
|
||||
const { layoutMode } = explorer.useSettingsSnapshot();
|
||||
const quickPreview = useQuickPreviewContext();
|
||||
const quickPreviewStore = useQuickPreviewStore();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const viewPadding = useExplorerViewPadding(padding);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
useKeyDownHandlers({
|
||||
disabled: isRenaming || quickPreviewStore.open
|
||||
});
|
||||
const selectable =
|
||||
explorer.selectable &&
|
||||
!explorerStore.isContextMenuOpen &&
|
||||
!explorerStore.isRenaming &&
|
||||
!quickPreviewStore.open;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isContextMenuOpen || explorer.selectedItems.size !== 0) return;
|
||||
// Close context menu when no items are selected
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
setIsContextMenuOpen(false);
|
||||
}, [explorer.selectedItems, isContextMenuOpen]);
|
||||
// Can stay here until we add columns view
|
||||
// Once added, the provided parent related logic should move to useExplorerDroppable
|
||||
// that way we don't have to re-use the same logic for each view
|
||||
const { setDroppableRef } = useExplorerDroppable({
|
||||
...(explorer.parent?.type === 'Location' && {
|
||||
allow: ['Path', 'NonIndexedPath'],
|
||||
data: { type: 'location', path: path ?? '/', data: explorer.parent.location },
|
||||
disabled:
|
||||
explorerStore.drag?.type === 'dragging' &&
|
||||
explorer.parent.location.id === explorerStore.drag.sourceLocationId &&
|
||||
(path ?? '/') === explorerStore.drag.sourcePath
|
||||
}),
|
||||
...(explorer.parent?.type === 'Ephemeral' && {
|
||||
allow: ['Path', 'NonIndexedPath'],
|
||||
data: { type: 'location', path: explorer.parent.path },
|
||||
disabled:
|
||||
explorerStore.drag?.type === 'dragging' &&
|
||||
explorer.parent.path === explorerStore.drag.sourcePath
|
||||
}),
|
||||
...(explorer.parent?.type === 'Tag' && {
|
||||
allow: 'Path',
|
||||
data: { type: 'tag', data: explorer.parent.tag },
|
||||
disabled:
|
||||
explorerStore.drag?.type === 'dragging' &&
|
||||
explorer.parent.tag.id === explorerStore.drag.sourceTagId
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (explorer.isFetchingNextPage) {
|
||||
const timer = setTimeout(() => setShowLoading(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
} else setShowLoading(false);
|
||||
}, [explorer.isFetchingNextPage]);
|
||||
useShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
if (explorer.layouts[layoutMode]) return;
|
||||
// If the current layout mode is not available, switch to the first available layout mode
|
||||
const layout = (Object.keys(explorer.layouts) as ExplorerLayout[]).find(
|
||||
(key) => explorer.layouts[key]
|
||||
);
|
||||
explorer.settingsStore.layoutMode = layout ?? 'grid';
|
||||
}, [layoutMode, explorer.layouts, explorer.settingsStore]);
|
||||
useEffect(() => {
|
||||
if (!explorerStore.isContextMenuOpen || explorer.selectedItems.size !== 0) return;
|
||||
// Close context menu when no items are selected
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
getExplorerStore().isContextMenuOpen = false;
|
||||
}, [explorer.selectedItems, explorerStore.isContextMenuOpen]);
|
||||
|
||||
useShortcut('openObject', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (quickPreviewStore.open || isRenaming) return;
|
||||
doubleClick();
|
||||
});
|
||||
useEffect(() => {
|
||||
if (explorer.isFetchingNextPage) {
|
||||
const timer = setTimeout(() => setShowLoading(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
} else setShowLoading(false);
|
||||
}, [explorer.isFetchingNextPage]);
|
||||
|
||||
useShortcut('showImageSlider', (e) => {
|
||||
e.stopPropagation();
|
||||
getExplorerLayoutStore().showImageSlider = !layoutStore.showImageSlider;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (explorer.layouts[layoutMode]) return;
|
||||
// If the current layout mode is not available, switch to the first available layout mode
|
||||
const layout = (Object.keys(explorer.layouts) as ExplorerLayout[]).find(
|
||||
(key) => explorer.layouts[key]
|
||||
);
|
||||
explorer.settingsStore.layoutMode = layout ?? 'grid';
|
||||
}, [layoutMode, explorer.layouts, explorer.settingsStore]);
|
||||
|
||||
useShortcut('toggleQuickPreview', (e) => {
|
||||
if (isRenaming) return;
|
||||
e.preventDefault();
|
||||
getQuickPreviewStore().open = !quickPreviewStore.open;
|
||||
});
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const store = getExplorerStore();
|
||||
store.isRenaming = false;
|
||||
store.isContextMenuOpen = false;
|
||||
store.isDragSelecting = false;
|
||||
};
|
||||
}, [layoutMode]);
|
||||
|
||||
useKeyCopyCutPaste();
|
||||
// Handle wheel scroll while dragging items
|
||||
useEffect(() => {
|
||||
const element = explorer.scrollRef.current;
|
||||
if (!element || explorerStore.drag?.type !== 'dragging') return;
|
||||
|
||||
if (!explorer.layouts[layoutMode]) return null;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
element.scrollBy({ top: e.deltaY });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className={clsx('h-full w-full', className)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2 || (e.button === 0 && e.shiftKey)) return;
|
||||
element.addEventListener('wheel', handleWheel);
|
||||
return () => element.removeEventListener('wheel', handleWheel);
|
||||
}, [explorer.scrollRef, explorerStore.drag?.type]);
|
||||
|
||||
explorer.resetSelectedItems();
|
||||
}}
|
||||
>
|
||||
if (!explorer.layouts[layoutMode]) return null;
|
||||
|
||||
return (
|
||||
<ViewContext.Provider value={{ ref, ...contextProps, selectable }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex flex-1"
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2 || (e.button === 0 && e.shiftKey)) return;
|
||||
explorer.selectedItems.size !== 0 && explorer.resetSelectedItems();
|
||||
}}
|
||||
>
|
||||
<div ref={setDroppableRef} className="h-full w-full">
|
||||
{explorer.items === null || (explorer.items && explorer.items.length > 0) ? (
|
||||
<ViewContext.Provider
|
||||
value={{
|
||||
...contextProps,
|
||||
selectable:
|
||||
explorer.selectable &&
|
||||
!isContextMenuOpen &&
|
||||
!isRenaming &&
|
||||
!quickPreviewStore.open,
|
||||
ref,
|
||||
isRenaming,
|
||||
isContextMenuOpen,
|
||||
setIsRenaming,
|
||||
setIsContextMenuOpen,
|
||||
padding: viewPadding
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{layoutMode === 'grid' && <GridView />}
|
||||
{layoutMode === 'list' && <ListView />}
|
||||
{layoutMode === 'media' && <MediaView />}
|
||||
{showLoading && (
|
||||
<Loader className="fixed bottom-10 left-0 w-[calc(100%+180px)]" />
|
||||
)}
|
||||
</ViewContext.Provider>
|
||||
</>
|
||||
) : (
|
||||
emptyNotice
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quickPreview.ref && createPortal(<QuickPreview />, quickPreview.ref)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
{/* TODO: Move when adding columns view */}
|
||||
<DragScrollable />
|
||||
|
||||
export const EmptyNotice = (props: {
|
||||
icon?: Icon | ReactNode;
|
||||
message?: ReactNode;
|
||||
loading?: boolean;
|
||||
}) => {
|
||||
const { layoutMode } = useExplorerContext().useSettingsSnapshot();
|
||||
|
||||
const emptyNoticeIcon = (icon?: Icon) => {
|
||||
const Icon =
|
||||
icon ??
|
||||
{
|
||||
grid: GridFour,
|
||||
media: MonitorPlay,
|
||||
columns: Columns,
|
||||
list: Rows
|
||||
}[layoutMode];
|
||||
|
||||
return <Icon size={100} opacity={0.3} />;
|
||||
};
|
||||
|
||||
if (props.loading) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center text-ink-faint">
|
||||
{props.icon
|
||||
? isValidElement(props.icon)
|
||||
? props.icon
|
||||
: emptyNoticeIcon(props.icon as Icon)
|
||||
: emptyNoticeIcon()}
|
||||
|
||||
<p className="mt-5 text-sm font-medium">
|
||||
{props.message !== undefined ? props.message : 'This list is empty'}
|
||||
</p>
|
||||
</div>
|
||||
{quickPreview.ref && createPortal(<QuickPreview />, quickPreview.ref)}
|
||||
</ViewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useKeyDownHandlers = ({ disabled }: { disabled: boolean }) => {
|
||||
const useShortcuts = () => {
|
||||
const explorer = useExplorerContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
const quickPreviewStore = useQuickPreviewStore();
|
||||
|
||||
const os = useOperatingSystem();
|
||||
const meta = useKeyMatcher('Meta');
|
||||
const { doubleClick } = useViewItemDoubleClick();
|
||||
|
||||
const handleNewTag = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
const objects: Object[] = [];
|
||||
useKeyCopyCutPaste();
|
||||
|
||||
for (const item of explorer.selectedItems) {
|
||||
const object = getItemObject(item);
|
||||
if (!object) return;
|
||||
objects.push(object);
|
||||
}
|
||||
useShortcut('toggleQuickPreview', (e) => {
|
||||
if (explorerStore.isRenaming) return;
|
||||
e.preventDefault();
|
||||
getQuickPreviewStore().open = !quickPreviewStore.open;
|
||||
});
|
||||
|
||||
if (
|
||||
!isNonEmpty(objects) ||
|
||||
event.key.toUpperCase() !== 'N' ||
|
||||
!event.getModifierState(os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control)
|
||||
)
|
||||
return;
|
||||
useShortcut('openObject', (e) => {
|
||||
if (explorerStore.isRenaming || quickPreviewStore.open) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
doubleClick();
|
||||
});
|
||||
|
||||
dialogManager.create((dp) => (
|
||||
<CreateDialog {...dp} items={objects.map((item) => ({ type: 'Object', item }))} />
|
||||
));
|
||||
},
|
||||
[os, explorer.selectedItems]
|
||||
);
|
||||
useShortcut('showImageSlider', (e) => {
|
||||
if (explorerStore.isRenaming) return;
|
||||
e.stopPropagation();
|
||||
getExplorerLayoutStore().showImageSlider = !getExplorerLayoutStore().showImageSlider;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handlers = [handleNewTag];
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.repeat || disabled) return;
|
||||
for (const handler of handlers) handler(event);
|
||||
};
|
||||
document.body.addEventListener('keydown', handler);
|
||||
return () => document.body.removeEventListener('keydown', handler);
|
||||
}, [disabled, handleNewTag]);
|
||||
useKeys([meta.key, 'KeyN'], () => {
|
||||
if (explorerStore.isRenaming || quickPreviewStore.open) return;
|
||||
|
||||
const objects: Object[] = [];
|
||||
|
||||
for (const item of explorer.selectedItems) {
|
||||
const object = getItemObject(item);
|
||||
if (!object) return;
|
||||
objects.push(object);
|
||||
}
|
||||
|
||||
if (!isNonEmpty(objects)) return;
|
||||
|
||||
dialogManager.create((dp) => (
|
||||
<CreateDialog {...dp} items={objects.map((item) => ({ type: 'Object', item }))} />
|
||||
));
|
||||
});
|
||||
};
|
||||
|
|
63
interface/app/$libraryId/Explorer/View/useDragScrollable.tsx
Normal file
63
interface/app/$libraryId/Explorer/View/useDragScrollable.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { getExplorerStore } from '../store';
|
||||
|
||||
/**
|
||||
* Custom explorer dnd scroll handler as the default auto-scroll from dnd-kit is presenting issues
|
||||
*/
|
||||
export const useDragScrollable = ({ direction }: { direction: 'up' | 'down' }) => {
|
||||
const explorer = useExplorerContext();
|
||||
|
||||
const [node, setNode] = useState<HTMLElement | null>(null);
|
||||
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const interval = useRef<NodeJS.Timer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = node;
|
||||
const scrollElement = explorer.scrollRef.current;
|
||||
if (!element || !scrollElement) return;
|
||||
|
||||
const reset = () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = null;
|
||||
}
|
||||
|
||||
if (interval.current) {
|
||||
clearInterval(interval.current);
|
||||
interval.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
||||
if (getExplorerStore().drag?.type !== 'dragging') return reset();
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
const isInside =
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom;
|
||||
|
||||
if (!isInside) return reset();
|
||||
|
||||
if (timeout.current) return;
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
interval.current = setInterval(() => {
|
||||
scrollElement.scrollBy({ top: direction === 'up' ? -10 : 10 });
|
||||
}, 5);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
return () => window.removeEventListener('mouseover', handleMouseMove);
|
||||
}, [direction, explorer.scrollRef, node]);
|
||||
|
||||
const ref = useCallback((nodeElement: HTMLElement | null) => setNode(nodeElement), []);
|
||||
|
||||
return { ref };
|
||||
};
|
|
@ -1,17 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { ExplorerViewPadding } from '.';
|
||||
|
||||
export const useExplorerViewPadding = (padding?: number | ExplorerViewPadding) => {
|
||||
const getPadding = useCallback(
|
||||
(key: keyof ExplorerViewPadding) => (typeof padding === 'object' ? padding[key] : padding),
|
||||
[padding]
|
||||
);
|
||||
|
||||
return {
|
||||
top: getPadding('top') ?? getPadding('y'),
|
||||
bottom: getPadding('bottom') ?? getPadding('y'),
|
||||
left: getPadding('left') ?? getPadding('x'),
|
||||
right: getPadding('right') ?? getPadding('x')
|
||||
};
|
||||
};
|
|
@ -7,16 +7,19 @@ import { useTopBarContext } from '../TopBar/Layout';
|
|||
import { useExplorerContext } from './Context';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import DismissibleNotice from './DismissibleNotice';
|
||||
import { ExplorerPath, PATH_BAR_HEIGHT } from './ExplorerPath';
|
||||
import { Inspector, INSPECTOR_WIDTH } from './Inspector';
|
||||
import ExplorerContextMenu from './ParentContextMenu';
|
||||
import { getQuickPreviewStore } from './QuickPreview/store';
|
||||
import { getExplorerStore, useExplorerStore } from './store';
|
||||
import { useKeyRevealFinder } from './useKeyRevealFinder';
|
||||
import View, { EmptyNotice, ExplorerViewProps } from './View';
|
||||
import { ExplorerPath, PATH_BAR_HEIGHT } from './View/ExplorerPath';
|
||||
import { ExplorerViewProps, View } from './View';
|
||||
import { EmptyNotice } from './View/EmptyNotice';
|
||||
|
||||
import 'react-slidedown/lib/slidedown.css';
|
||||
|
||||
import { useExplorerDnd } from './useExplorerDnd';
|
||||
|
||||
interface Props {
|
||||
emptyNotice?: ExplorerViewProps['emptyNotice'];
|
||||
contextMenu?: () => ReactNode;
|
||||
|
@ -64,44 +67,43 @@ export default function Explorer(props: PropsWithChildren<Props>) {
|
|||
|
||||
useKeyRevealFinder();
|
||||
|
||||
useExplorerDnd();
|
||||
|
||||
const topBar = useTopBarContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExplorerContextMenu>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={explorer.scrollRef}
|
||||
className="custom-scroll explorer-scroll h-screen overflow-x-hidden"
|
||||
style={
|
||||
{
|
||||
'--scrollbar-margin-top': `${topBar.topBarHeight}px`,
|
||||
'--scrollbar-margin-bottom': `${
|
||||
showPathBar ? PATH_BAR_HEIGHT + 2 : 0 // TODO: Fix for web app
|
||||
}px`,
|
||||
'paddingTop': topBar.topBarHeight,
|
||||
'paddingRight': explorerStore.showInspector ? INSPECTOR_WIDTH : 0
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{explorer.items && explorer.items.length > 0 && <DismissibleNotice />}
|
||||
<div
|
||||
ref={explorer.scrollRef}
|
||||
className="custom-scroll explorer-scroll flex flex-1 flex-col overflow-x-hidden"
|
||||
style={
|
||||
{
|
||||
'--scrollbar-margin-top': `${topBar.topBarHeight}px`,
|
||||
'--scrollbar-margin-bottom': `${showPathBar ? PATH_BAR_HEIGHT : 0}px`,
|
||||
'paddingTop': topBar.topBarHeight,
|
||||
'paddingRight': explorerStore.showInspector ? INSPECTOR_WIDTH : 0
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{explorer.items && explorer.items.length > 0 && <DismissibleNotice />}
|
||||
|
||||
<View
|
||||
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
|
||||
emptyNotice={
|
||||
props.emptyNotice ?? (
|
||||
<EmptyNotice
|
||||
icon={FolderNotchOpen}
|
||||
message="This folder is empty"
|
||||
/>
|
||||
)
|
||||
}
|
||||
listViewOptions={{ hideHeaderBorder: true }}
|
||||
bottom={showPathBar ? PATH_BAR_HEIGHT : undefined}
|
||||
/>
|
||||
</div>
|
||||
<View
|
||||
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
|
||||
emptyNotice={
|
||||
props.emptyNotice ?? (
|
||||
<EmptyNotice
|
||||
icon={FolderNotchOpen}
|
||||
message="This folder is empty"
|
||||
/>
|
||||
)
|
||||
}
|
||||
listViewOptions={{ hideHeaderBorder: true }}
|
||||
bottom={showPathBar ? PATH_BAR_HEIGHT : undefined}
|
||||
/>
|
||||
</div>
|
||||
</ExplorerContextMenu>
|
||||
|
||||
{showPathBar && <ExplorerPath />}
|
||||
|
||||
{explorerStore.showInspector && (
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
import { proxySet } from 'valtio/utils';
|
||||
import { z } from 'zod';
|
||||
|
@ -107,7 +106,7 @@ export const createDefaultExplorerSettings = <TOrder extends Ordering>(args?: {
|
|||
}
|
||||
}) satisfies ExplorerSettings<TOrder>;
|
||||
|
||||
type CutCopyState =
|
||||
export type CutCopyState =
|
||||
| {
|
||||
type: 'Idle';
|
||||
}
|
||||
|
@ -123,6 +122,18 @@ type CutCopyState =
|
|||
};
|
||||
};
|
||||
|
||||
type DragState =
|
||||
| {
|
||||
type: 'touched';
|
||||
}
|
||||
| {
|
||||
type: 'dragging';
|
||||
items: ExplorerItem[];
|
||||
sourcePath?: string;
|
||||
sourceLocationId?: number;
|
||||
sourceTagId?: number;
|
||||
};
|
||||
|
||||
const state = {
|
||||
tagAssignMode: false,
|
||||
showInspector: false,
|
||||
|
@ -131,7 +142,10 @@ const state = {
|
|||
mediaPlayerVolume: 0.7,
|
||||
newThumbnails: proxySet() as Set<string>,
|
||||
cutCopyState: { type: 'Idle' } as CutCopyState,
|
||||
isDragging: false
|
||||
drag: null as null | DragState,
|
||||
isDragSelecting: false,
|
||||
isRenaming: false,
|
||||
isContextMenuOpen: false
|
||||
};
|
||||
|
||||
export function flattenThumbnailKey(thumbKey: string[]) {
|
||||
|
@ -160,7 +174,7 @@ export function getExplorerStore() {
|
|||
return explorerStore;
|
||||
}
|
||||
|
||||
export function isCut(item: ExplorerItem, cutCopyState: ReadonlyDeep<CutCopyState>) {
|
||||
export function isCut(item: ExplorerItem, cutCopyState: CutCopyState) {
|
||||
switch (item.type) {
|
||||
case 'NonIndexedPath':
|
||||
return (
|
||||
|
|
211
interface/app/$libraryId/Explorer/useExplorerDnd.tsx
Normal file
211
interface/app/$libraryId/Explorer/useExplorerDnd.tsx
Normal file
|
@ -0,0 +1,211 @@
|
|||
import { useDndMonitor } from '@dnd-kit/core';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ExplorerItem,
|
||||
getIndexedItemFilePath,
|
||||
getItemFilePath,
|
||||
libraryClient,
|
||||
useLibraryMutation,
|
||||
useZodForm
|
||||
} from '@sd/client';
|
||||
import { Dialog, RadixCheckbox, useDialog, UseDialogProps } from '@sd/ui';
|
||||
import { Icon } from '~/components';
|
||||
import { isNonEmptyObject } from '~/util';
|
||||
|
||||
import { useAssignItemsToTag } from '../settings/library/tags/CreateDialog';
|
||||
import { useExplorerContext } from './Context';
|
||||
import { getExplorerStore } from './store';
|
||||
import { explorerDroppableSchema } from './useExplorerDroppable';
|
||||
import { useExplorerSearchParams } from './util';
|
||||
|
||||
const getPaths = async (items: ExplorerItem[]) => {
|
||||
const paths = items.map(async (item) => {
|
||||
const filePath = getItemFilePath(item);
|
||||
if (!filePath) return;
|
||||
|
||||
return 'path' in filePath
|
||||
? filePath.path
|
||||
: await libraryClient.query(['files.getPath', filePath.id]);
|
||||
});
|
||||
|
||||
return (await Promise.all(paths)).filter((path): path is string => Boolean(path));
|
||||
};
|
||||
|
||||
const getPathIdsPerLocation = (items: ExplorerItem[]) => {
|
||||
return items.reduce(
|
||||
(items, item) => {
|
||||
const path = getIndexedItemFilePath(item);
|
||||
if (!path || path.location_id === null) return items;
|
||||
|
||||
return {
|
||||
...items,
|
||||
[path.location_id]: [...(items[path.location_id] ?? []), path.id]
|
||||
};
|
||||
},
|
||||
{} as Record<number, number[]>
|
||||
);
|
||||
};
|
||||
|
||||
export const useExplorerDnd = () => {
|
||||
const explorer = useExplorerContext();
|
||||
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const cutFiles = useLibraryMutation('files.cutFiles');
|
||||
const cutEphemeralFiles = useLibraryMutation('ephemeralFiles.cutFiles');
|
||||
const assignItemsToTag = useAssignItemsToTag();
|
||||
|
||||
useDndMonitor({
|
||||
onDragStart: () => {
|
||||
if (explorer.selectedItems.size === 0) return;
|
||||
getExplorerStore().drag = {
|
||||
type: 'dragging',
|
||||
items: [...explorer.selectedItems],
|
||||
sourcePath: path ?? '/',
|
||||
sourceLocationId:
|
||||
explorer.parent?.type === 'Location' ? explorer.parent.location.id : undefined,
|
||||
sourceTagId: explorer.parent?.type === 'Tag' ? explorer.parent.tag.id : undefined
|
||||
};
|
||||
},
|
||||
onDragEnd: async ({ over }) => {
|
||||
const { drag } = getExplorerStore();
|
||||
getExplorerStore().drag = null;
|
||||
|
||||
if (!over || !drag || drag.type === 'touched') return;
|
||||
|
||||
const drop = explorerDroppableSchema.parse(over.data.current);
|
||||
|
||||
switch (drop.type) {
|
||||
case 'location': {
|
||||
if (!drop.data) {
|
||||
cutEphemeralFiles.mutate({
|
||||
sources: await getPaths(drag.items),
|
||||
target_dir: drop.path
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = getPathIdsPerLocation(drag.items);
|
||||
if (isNonEmptyObject(paths)) {
|
||||
const locationId = drop.data.id;
|
||||
|
||||
Object.entries(paths).map(([sourceLocationId, paths]) => {
|
||||
cutFiles.mutate({
|
||||
source_location_id: Number(sourceLocationId),
|
||||
sources_file_path_ids: paths,
|
||||
target_location_id: locationId,
|
||||
target_location_relative_directory_path: drop.path
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
cutEphemeralFiles.mutate({
|
||||
sources: await getPaths(drag.items),
|
||||
target_dir: drop.data.path + drop.path
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'explorer-item': {
|
||||
switch (drop.data.type) {
|
||||
case 'Path':
|
||||
case 'Object': {
|
||||
const { item } = drop.data;
|
||||
|
||||
const filePath = 'file_paths' in item ? item.file_paths[0] : item;
|
||||
if (!filePath) return;
|
||||
|
||||
const paths = getPathIdsPerLocation(drag.items);
|
||||
if (isNonEmptyObject(paths)) {
|
||||
const locationId = filePath.location_id;
|
||||
const path = filePath.materialized_path + filePath.name + '/';
|
||||
|
||||
Object.entries(paths).map(([sourceLocationId, paths]) => {
|
||||
cutFiles.mutate({
|
||||
source_location_id: Number(sourceLocationId),
|
||||
sources_file_path_ids: paths,
|
||||
target_location_id: locationId,
|
||||
target_location_relative_directory_path: path
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const path = await libraryClient.query(['files.getPath', filePath.id]);
|
||||
if (!path) return;
|
||||
|
||||
cutEphemeralFiles.mutate({
|
||||
sources: await getPaths(drag.items),
|
||||
target_dir: path
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Location':
|
||||
case 'NonIndexedPath': {
|
||||
cutEphemeralFiles.mutate({
|
||||
sources: await getPaths(drag.items),
|
||||
target_dir: drop.data.item.path
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tag': {
|
||||
const items = drag.items.flatMap((item) => {
|
||||
if (item.type !== 'Object' && item.type !== 'Path') return [];
|
||||
return [item];
|
||||
});
|
||||
await assignItemsToTag(drop.data.id, items);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragCancel: () => (getExplorerStore().drag = null)
|
||||
});
|
||||
};
|
||||
|
||||
interface DndNoticeProps extends UseDialogProps {
|
||||
count: number;
|
||||
path: string;
|
||||
onConfirm: (val: { dismissNotice: boolean }) => void;
|
||||
}
|
||||
|
||||
const DndNotice = (props: DndNoticeProps) => {
|
||||
const form = useZodForm();
|
||||
const [dismissNotice, setDismissNotice] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
form={form}
|
||||
onSubmit={form.handleSubmit(() => props.onConfirm({ dismissNotice: dismissNotice }))}
|
||||
dialog={useDialog(props)}
|
||||
title="Move Files"
|
||||
icon={<Icon name="MoveLocation" size={28} />}
|
||||
description={
|
||||
<span className="break-all">
|
||||
Are you sure you want to move {props.count} file{props.count > 1 ? 's' : ''} to{' '}
|
||||
{props.path}?
|
||||
</span>
|
||||
}
|
||||
ctaDanger
|
||||
ctaLabel="Continue"
|
||||
closeLabel="Cancel"
|
||||
buttonsSideContent={
|
||||
<RadixCheckbox
|
||||
label="Don't show again"
|
||||
name="ephemeral-alert-notice"
|
||||
checked={dismissNotice}
|
||||
onCheckedChange={(val) => typeof val === 'boolean' && setDismissNotice(val)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
50
interface/app/$libraryId/Explorer/useExplorerDraggable.tsx
Normal file
50
interface/app/$libraryId/Explorer/useExplorerDraggable.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { useDraggable, UseDraggableArguments } from '@dnd-kit/core';
|
||||
import { CSSProperties, HTMLAttributes } from 'react';
|
||||
import { ExplorerItem } from '@sd/client';
|
||||
|
||||
import { getExplorerStore } from './store';
|
||||
import { uniqueId } from './util';
|
||||
|
||||
export interface UseExplorerDraggableProps extends Omit<UseDraggableArguments, 'id'> {
|
||||
data: ExplorerItem;
|
||||
}
|
||||
|
||||
const draggableTypes: ExplorerItem['type'][] = ['Path', 'NonIndexedPath', 'Object'];
|
||||
|
||||
export const useExplorerDraggable = (props: UseExplorerDraggableProps) => {
|
||||
const disabled = props.disabled || !draggableTypes.includes(props.data.type);
|
||||
|
||||
const { setNodeRef, ...draggable } = useDraggable({
|
||||
...props,
|
||||
id: uniqueId(props.data),
|
||||
disabled: disabled
|
||||
});
|
||||
|
||||
const onMouseDown = () => {
|
||||
if (!disabled) getExplorerStore().drag = { type: 'touched' };
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
const explorerStore = getExplorerStore();
|
||||
if (explorerStore.drag?.type !== 'dragging') explorerStore.drag = null;
|
||||
};
|
||||
|
||||
const onMouseUp = () => (getExplorerStore().drag = null);
|
||||
|
||||
const style = {
|
||||
cursor: 'default',
|
||||
outline: 'none'
|
||||
} satisfies CSSProperties;
|
||||
|
||||
return {
|
||||
...draggable,
|
||||
setDraggableRef: setNodeRef,
|
||||
listeners: {
|
||||
...draggable.listeners,
|
||||
onMouseDown,
|
||||
onMouseLeave,
|
||||
onMouseUp
|
||||
} satisfies HTMLAttributes<Element>,
|
||||
style
|
||||
};
|
||||
};
|
211
interface/app/$libraryId/Explorer/useExplorerDroppable.tsx
Normal file
211
interface/app/$libraryId/Explorer/useExplorerDroppable.tsx
Normal file
|
@ -0,0 +1,211 @@
|
|||
import { useDroppable, UseDroppableArguments } from '@dnd-kit/core';
|
||||
import { useEffect, useId, useMemo, useState } from 'react';
|
||||
import { NavigateOptions, To, useNavigate } from 'react-router';
|
||||
import { createSearchParams } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
import { ExplorerItem, getItemFilePath, Location, Tag } from '@sd/client';
|
||||
|
||||
import { useExplorerContext } from './Context';
|
||||
import { getExplorerStore } from './store';
|
||||
|
||||
type ExplorerItemType = ExplorerItem['type'];
|
||||
|
||||
const droppableTypes = [
|
||||
'Location',
|
||||
'NonIndexedPath',
|
||||
'Object',
|
||||
'Path',
|
||||
'SpacedropPeer'
|
||||
] satisfies ExplorerItemType[];
|
||||
|
||||
export interface UseExplorerDroppableProps extends Omit<UseDroppableArguments, 'id'> {
|
||||
id?: string;
|
||||
data?:
|
||||
| {
|
||||
type: 'location';
|
||||
data?: Location | z.infer<typeof explorerLocationSchema>['data'];
|
||||
path: string;
|
||||
}
|
||||
| { type: 'explorer-item'; data: ExplorerItem }
|
||||
| { type: 'tag'; data: Tag };
|
||||
allow?: ExplorerItemType | ExplorerItemType[];
|
||||
navigateTo?: To | { to: To; options?: NavigateOptions } | number | (() => void);
|
||||
}
|
||||
|
||||
const explorerPathSchema = z.object({
|
||||
type: z.literal('Path'),
|
||||
item: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
location_id: z.number(),
|
||||
materialized_path: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
const explorerObjectSchema = z.object({
|
||||
type: z.literal('Object'),
|
||||
item: z.object({
|
||||
file_paths: explorerPathSchema.shape.item.array()
|
||||
})
|
||||
});
|
||||
|
||||
const explorerNonIndexedPathSchema = z.object({
|
||||
type: z.literal('NonIndexedPath'),
|
||||
item: z.object({
|
||||
name: z.string(),
|
||||
path: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
const explorerItemLocationSchema = z.object({
|
||||
type: z.literal('Location'),
|
||||
item: z.object({ id: z.number(), path: z.string() })
|
||||
});
|
||||
|
||||
const explorerItemSchema = z.object({
|
||||
type: z.literal('explorer-item'),
|
||||
data: explorerPathSchema
|
||||
.or(explorerNonIndexedPathSchema)
|
||||
.or(explorerItemLocationSchema)
|
||||
.or(explorerObjectSchema)
|
||||
});
|
||||
|
||||
const explorerLocationSchema = z.object({
|
||||
type: z.literal('location'),
|
||||
data: z.object({ id: z.number(), path: z.string() }).optional(),
|
||||
path: z.string()
|
||||
});
|
||||
|
||||
const explorerTagSchema = z.object({
|
||||
type: z.literal('tag'),
|
||||
data: z.object({ id: z.number() })
|
||||
});
|
||||
|
||||
export const explorerDroppableSchema = explorerItemSchema
|
||||
.or(explorerLocationSchema)
|
||||
.or(explorerTagSchema);
|
||||
|
||||
export const useExplorerDroppable = ({
|
||||
allow,
|
||||
navigateTo,
|
||||
...props
|
||||
}: UseExplorerDroppableProps) => {
|
||||
const id = useId();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const explorer = useExplorerContext({ suspense: false });
|
||||
|
||||
const [canNavigate, setCanNavigate] = useState(true);
|
||||
|
||||
const { setNodeRef, ...droppable } = useDroppable({
|
||||
...props,
|
||||
id: props.id ?? id,
|
||||
disabled: (!props.data && !navigateTo) || props.disabled
|
||||
});
|
||||
|
||||
const isDroppable = useMemo(() => {
|
||||
if (!droppable.isOver) return false;
|
||||
|
||||
const { drag } = getExplorerStore();
|
||||
if (!drag || drag.type === 'touched') return false;
|
||||
|
||||
let allowedType: ExplorerItemType | ExplorerItemType[] | undefined = allow;
|
||||
|
||||
if (!allowedType) {
|
||||
if (explorer?.parent) {
|
||||
switch (explorer.parent.type) {
|
||||
case 'Location':
|
||||
case 'Ephemeral': {
|
||||
allowedType = ['Path', 'NonIndexedPath', 'Object'];
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Tag': {
|
||||
allowedType = ['Path', 'Object'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (props.data?.type === 'explorer-item') {
|
||||
switch (props.data.data.type) {
|
||||
case 'Path':
|
||||
case 'NonIndexedPath': {
|
||||
allowedType = ['Path', 'NonIndexedPath', 'Object'];
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Object': {
|
||||
allowedType = ['Path', 'Object'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else allowedType = droppableTypes;
|
||||
|
||||
if (!allowedType) return false;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
type: Array.isArray(allowedType)
|
||||
? z.union(
|
||||
allowedType.map((type) => z.literal(type)) as unknown as [
|
||||
z.ZodLiteral<ExplorerItemType>,
|
||||
z.ZodLiteral<ExplorerItemType>,
|
||||
...z.ZodLiteral<ExplorerItemType>[]
|
||||
]
|
||||
)
|
||||
: z.literal(allowedType)
|
||||
});
|
||||
|
||||
return schema.safeParse(drag.items[0]).success;
|
||||
}, [allow, droppable.isOver, explorer?.parent, props.data]);
|
||||
|
||||
const filePath = props.data?.type === 'explorer-item' && getItemFilePath(props.data.data);
|
||||
const isLocation = props.data?.type === 'explorer-item' && props.data.data.type === 'Location';
|
||||
|
||||
const isNavigable = isDroppable && canNavigate && (filePath || navigateTo || isLocation);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNavigable) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (navigateTo) {
|
||||
if (typeof navigateTo === 'function') {
|
||||
navigateTo();
|
||||
} else if (typeof navigateTo === 'object' && 'to' in navigateTo) {
|
||||
navigate(navigateTo.to, navigateTo.options);
|
||||
} else if (typeof navigateTo === 'number') {
|
||||
navigate(navigateTo);
|
||||
} else {
|
||||
navigate(navigateTo);
|
||||
}
|
||||
} else if (filePath) {
|
||||
if ('id' in filePath) {
|
||||
navigate({
|
||||
pathname: `../location/${filePath.location_id}`,
|
||||
search: `${createSearchParams({
|
||||
path: `${filePath.materialized_path}${filePath.name}/`
|
||||
})}`
|
||||
});
|
||||
} else {
|
||||
navigate({ search: `${createSearchParams({ path: filePath.path })}` });
|
||||
}
|
||||
} else if (
|
||||
props.data?.type === 'explorer-item' &&
|
||||
props.data.data.type === 'Location'
|
||||
) {
|
||||
navigate(`../location/${props.data.data.item.id}`);
|
||||
}
|
||||
|
||||
// Timeout navigation
|
||||
setCanNavigate(false);
|
||||
setTimeout(() => setCanNavigate(true), 1250);
|
||||
}, 1250);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [navigate, props.data, navigateTo, filePath, isNavigable]);
|
||||
|
||||
const className = isNavigable
|
||||
? 'animate-pulse transition-opacity duration-200 [animation-delay:1000ms]'
|
||||
: undefined;
|
||||
|
||||
return { setDroppableRef: setNodeRef, ...droppable, isDroppable, className };
|
||||
};
|
24
interface/app/$libraryId/Layout/DndContext.tsx
Normal file
24
interface/app/$libraryId/Layout/DndContext.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as Dnd from '@dnd-kit/core';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
export const DndContext = ({ children }: PropsWithChildren) => {
|
||||
const sensors = Dnd.useSensors(
|
||||
Dnd.useSensor(Dnd.PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 4
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<Dnd.DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={Dnd.pointerWithin}
|
||||
// We handle scrolling ourselves as dnd-kit
|
||||
// auto-scroll is causing issues
|
||||
autoScroll={{ enabled: false }}
|
||||
>
|
||||
{children}
|
||||
</Dnd.DndContext>
|
||||
);
|
||||
};
|
41
interface/app/$libraryId/Layout/Sidebar/Devices/index.tsx
Normal file
41
interface/app/$libraryId/Layout/Sidebar/Devices/index.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { useBridgeQuery, useFeatureFlag } from '@sd/client';
|
||||
import { Button, Tooltip } from '@sd/ui';
|
||||
import { Icon, SubtleButton } from '~/components';
|
||||
|
||||
import SidebarLink from '../Link';
|
||||
import Section from '../Section';
|
||||
|
||||
export const Devices = () => {
|
||||
const { data: node } = useBridgeQuery(['nodeState']);
|
||||
const isPairingEnabled = useFeatureFlag('p2pPairing');
|
||||
|
||||
return (
|
||||
<Section
|
||||
name="Devices"
|
||||
actionArea={
|
||||
isPairingEnabled && (
|
||||
<Link to="settings/library/nodes">
|
||||
<SubtleButton />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
>
|
||||
{node && (
|
||||
<SidebarLink className="group relative w-full" to={`node/${node.id}`} key={node.id}>
|
||||
<Icon name="Laptop" className="mr-1 h-5 w-5" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</SidebarLink>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
label="Coming soon! This alpha release doesn't include library sync, it will be ready very soon."
|
||||
position="right"
|
||||
>
|
||||
<Button disabled variant="dotted" className="mt-1 w-full">
|
||||
Add Device
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
);
|
||||
};
|
|
@ -1,11 +1,13 @@
|
|||
import { EjectSimple } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
import { PropsWithChildren, useMemo } from 'react';
|
||||
import { useBridgeQuery, useCache, useLibraryQuery, useNodes } from '@sd/client';
|
||||
import { Button, toast, tw } from '@sd/ui';
|
||||
import { Icon, IconName } from '~/components';
|
||||
import { useHomeDir } from '~/hooks/useHomeDir';
|
||||
|
||||
import { useExplorerDroppable } from '../../Explorer/useExplorerDroppable';
|
||||
import { useExplorerSearchParams } from '../../Explorer/util';
|
||||
import SidebarLink from './Link';
|
||||
import Section from './Section';
|
||||
import { SeeMore } from './SeeMore';
|
||||
|
@ -78,36 +80,45 @@ export const EphemeralSection = () => {
|
|||
<SidebarIcon name="Globe" />
|
||||
<Name>Network</Name>
|
||||
</SidebarLink>
|
||||
|
||||
{homeDir.data && (
|
||||
<SidebarLink
|
||||
to={`ephemeral/0?path=${homeDir.data}`}
|
||||
className="group relative w-full border border-transparent"
|
||||
<EphemeralLocation
|
||||
navigateTo={`ephemeral/0?path=${homeDir.data}`}
|
||||
path={homeDir.data ?? ''}
|
||||
>
|
||||
<SidebarIcon name="Home" />
|
||||
<Name>Home</Name>
|
||||
</SidebarLink>
|
||||
</EphemeralLocation>
|
||||
)}
|
||||
|
||||
{mountPoints.map((item) => {
|
||||
if (!item) return;
|
||||
|
||||
const locationId = locationIdsForVolumes[item.mountPoint ?? ''];
|
||||
|
||||
const key = `${item.volumeIndex}-${item.index}`;
|
||||
|
||||
const name =
|
||||
item.mountPoint === '/'
|
||||
? 'Root'
|
||||
: item.index === 0
|
||||
? item.volume.name
|
||||
: item.mountPoint;
|
||||
|
||||
const toPath =
|
||||
locationId !== undefined
|
||||
? `location/${locationId}`
|
||||
: `ephemeral/${key}?path=${item.mountPoint}`;
|
||||
|
||||
return (
|
||||
<SidebarLink
|
||||
to={toPath}
|
||||
<EphemeralLocation
|
||||
key={key}
|
||||
className="group relative w-full border border-transparent"
|
||||
navigateTo={toPath}
|
||||
path={
|
||||
locationId !== undefined
|
||||
? locationId.toString()
|
||||
: item.mountPoint ?? ''
|
||||
}
|
||||
>
|
||||
<SidebarIcon
|
||||
name={
|
||||
|
@ -120,10 +131,40 @@ export const EphemeralSection = () => {
|
|||
/>
|
||||
<Name>{name}</Name>
|
||||
{item.volume.disk_type === 'Removable' && <EjectButton />}
|
||||
</SidebarLink>
|
||||
</EphemeralLocation>
|
||||
);
|
||||
})}
|
||||
</SeeMore>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const EphemeralLocation = ({
|
||||
children,
|
||||
path,
|
||||
navigateTo
|
||||
}: PropsWithChildren<{ path: string; navigateTo: string }>) => {
|
||||
const [{ path: ephemeralPath }] = useExplorerSearchParams();
|
||||
|
||||
const { isDroppable, className, setDroppableRef } = useExplorerDroppable({
|
||||
id: `sidebar-ephemeral-location-${path}`,
|
||||
allow: ['Path', 'NonIndexedPath', 'Object'],
|
||||
data: { type: 'location', path },
|
||||
disabled: navigateTo.startsWith('location/') || ephemeralPath === path,
|
||||
navigateTo: navigateTo
|
||||
});
|
||||
|
||||
return (
|
||||
<SidebarLink
|
||||
ref={setDroppableRef}
|
||||
to={navigateTo}
|
||||
className={clsx(
|
||||
'border',
|
||||
isDroppable ? ' border-accent' : 'border-transparent',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SidebarLink>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,219 +1,15 @@
|
|||
import { X } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { useMatch, useNavigate, useResolvedPath } from 'react-router';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import {
|
||||
arraysEqual,
|
||||
useBridgeQuery,
|
||||
useCache,
|
||||
useFeatureFlag,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery,
|
||||
useNodes,
|
||||
useOnlineLocations
|
||||
} from '@sd/client';
|
||||
import { Button, Tooltip } from '@sd/ui';
|
||||
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
|
||||
import { Folder, Icon, SubtleButton } from '~/components';
|
||||
|
||||
import SidebarLink from './Link';
|
||||
import LocationsContextMenu from './LocationsContextMenu';
|
||||
import Section from './Section';
|
||||
import { SeeMore } from './SeeMore';
|
||||
import TagsContextMenu from './TagsContextMenu';
|
||||
|
||||
export const LibrarySection = () => (
|
||||
<>
|
||||
<SavedSearches />
|
||||
<Devices />
|
||||
<Locations />
|
||||
<Tags />
|
||||
</>
|
||||
);
|
||||
|
||||
function SavedSearches() {
|
||||
const savedSearches = useLibraryQuery(['search.saved.list']);
|
||||
|
||||
const path = useResolvedPath('saved-search/:id');
|
||||
const match = useMatch(path.pathname);
|
||||
const currentSearchId = match?.params?.id;
|
||||
|
||||
const currentIndex = currentSearchId
|
||||
? savedSearches.data?.findIndex((s) => s.id === Number(currentSearchId))
|
||||
: undefined;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const deleteSavedSearch = useLibraryMutation(['search.saved.delete'], {
|
||||
onSuccess() {
|
||||
if (currentIndex !== undefined && savedSearches.data) {
|
||||
const nextIndex = Math.min(currentIndex + 1, savedSearches.data.length - 2);
|
||||
|
||||
const search = savedSearches.data[nextIndex];
|
||||
|
||||
if (search) navigate(`saved-search/${search.id}`);
|
||||
else navigate(`./`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!savedSearches.data || savedSearches.data.length < 1) return null;
|
||||
import { Devices } from './Devices';
|
||||
import { Locations } from './Locations';
|
||||
import { SavedSearches } from './SavedSearches';
|
||||
import { Tags } from './Tags';
|
||||
|
||||
export const LibrarySection = () => {
|
||||
return (
|
||||
<Section
|
||||
name="Saved Searches"
|
||||
// actionArea={
|
||||
// <Link to="settings/library/saved-searches">
|
||||
// <SubtleButton />
|
||||
// </Link>
|
||||
// }
|
||||
>
|
||||
<SeeMore>
|
||||
{savedSearches.data.map((search, i) => (
|
||||
<SidebarLink
|
||||
className="group/button relative w-full"
|
||||
to={`saved-search/${search.id}`}
|
||||
key={search.id}
|
||||
>
|
||||
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
|
||||
<Folder size={18} />
|
||||
</div>
|
||||
|
||||
<span className="truncate">{search.name}</span>
|
||||
|
||||
<Button
|
||||
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded-full shadow group-hover/button:block"
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
deleteSavedSearch.mutate(search.id);
|
||||
}}
|
||||
>
|
||||
<X size={10} weight="bold" className="text-ink-dull/50" />
|
||||
</Button>
|
||||
</SidebarLink>
|
||||
))}
|
||||
</SeeMore>
|
||||
</Section>
|
||||
<>
|
||||
<SavedSearches />
|
||||
<Devices />
|
||||
<Locations />
|
||||
<Tags />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Devices() {
|
||||
const node = useBridgeQuery(['nodeState']);
|
||||
const isPairingEnabled = useFeatureFlag('p2pPairing');
|
||||
|
||||
return (
|
||||
<Section
|
||||
name="Devices"
|
||||
actionArea={
|
||||
isPairingEnabled && (
|
||||
<Link to="settings/library/nodes">
|
||||
<SubtleButton />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
>
|
||||
{node.data && (
|
||||
<SidebarLink
|
||||
className="group relative w-full"
|
||||
to={`node/${node.data.id}`}
|
||||
key={node.data.id}
|
||||
>
|
||||
<Icon name="Laptop" size={20} className="mr-1" />
|
||||
<span className="truncate">{node.data.name}</span>
|
||||
</SidebarLink>
|
||||
)}
|
||||
<Tooltip
|
||||
label="Coming soon! This alpha release doesn't include library sync, it will be ready very soon."
|
||||
position="right"
|
||||
>
|
||||
<Button disabled variant="dotted" className="mt-1 w-full">
|
||||
Add Device
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Locations() {
|
||||
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
|
||||
useNodes(locationsQuery.data?.nodes);
|
||||
const locations = useCache(locationsQuery.data?.items);
|
||||
const onlineLocations = useOnlineLocations();
|
||||
|
||||
return (
|
||||
<Section
|
||||
name="Locations"
|
||||
actionArea={
|
||||
<Link to="settings/library/locations">
|
||||
<SubtleButton />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<SeeMore>
|
||||
{locations?.map((location) => (
|
||||
<LocationsContextMenu key={location.id} locationId={location.id}>
|
||||
<SidebarLink
|
||||
className="borderradix-state-closed:border-transparent group relative w-full radix-state-open:border-accent"
|
||||
to={`location/${location.id}`}
|
||||
>
|
||||
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
|
||||
<Icon name="Folder" size={18} />
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute bottom-0.5 right-0 h-1.5 w-1.5 rounded-full',
|
||||
onlineLocations.some((l) => arraysEqual(location.pub_id, l))
|
||||
? 'bg-green-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="truncate">{location.name}</span>
|
||||
</SidebarLink>
|
||||
</LocationsContextMenu>
|
||||
))}
|
||||
</SeeMore>
|
||||
<AddLocationButton className="mt-1" />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Tags() {
|
||||
const result = useLibraryQuery(['tags.list'], { keepPreviousData: true });
|
||||
useNodes(result.data?.nodes);
|
||||
const tags = useCache(result.data?.items);
|
||||
|
||||
if (!tags?.length) return;
|
||||
|
||||
return (
|
||||
<Section
|
||||
name="Tags"
|
||||
actionArea={
|
||||
<NavLink to="settings/library/tags">
|
||||
<SubtleButton />
|
||||
</NavLink>
|
||||
}
|
||||
>
|
||||
<SeeMore>
|
||||
{tags?.map((tag) => (
|
||||
<TagsContextMenu tagId={tag.id} key={tag.id}>
|
||||
<SidebarLink
|
||||
className="border radix-state-closed:border-transparent radix-state-open:border-accent"
|
||||
to={`tag/${tag.id}`}
|
||||
>
|
||||
<div
|
||||
className="h-[12px] w-[12px] shrink-0 rounded-full"
|
||||
style={{ backgroundColor: tag.color || '#efefef' }}
|
||||
/>
|
||||
<span className="ml-1.5 truncate text-sm">{tag.name}</span>
|
||||
</SidebarLink>
|
||||
</TagsContextMenu>
|
||||
))}
|
||||
</SeeMore>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Pencil, Plus, Trash } from '@phosphor-icons/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useLibraryContext } from '@sd/client';
|
||||
import { ContextMenu as CM, dialogManager, toast } from '@sd/ui';
|
||||
|
@ -7,12 +8,10 @@ import DeleteDialog from '~/app/$libraryId/settings/library/locations/DeleteDial
|
|||
import { openDirectoryPickerDialog } from '~/app/$libraryId/settings/library/locations/openDirectoryPickerDialog';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
locationId: number;
|
||||
}
|
||||
|
||||
export default ({ children, locationId }: Props) => {
|
||||
export const ContextMenu = ({
|
||||
children,
|
||||
locationId
|
||||
}: PropsWithChildren<{ locationId: number }>) => {
|
||||
const navigate = useNavigate();
|
||||
const platform = usePlatform();
|
||||
const libraryId = useLibraryContext().library.uuid;
|
87
interface/app/$libraryId/Layout/Sidebar/Locations/index.tsx
Normal file
87
interface/app/$libraryId/Layout/Sidebar/Locations/index.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import clsx from 'clsx';
|
||||
import { Link, useMatch } from 'react-router-dom';
|
||||
import {
|
||||
arraysEqual,
|
||||
Location as LocationType,
|
||||
useCache,
|
||||
useLibraryQuery,
|
||||
useNodes,
|
||||
useOnlineLocations
|
||||
} from '@sd/client';
|
||||
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
|
||||
import { useExplorerSearchParams } from '~/app/$libraryId/Explorer/util';
|
||||
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
|
||||
import { Icon, SubtleButton } from '~/components';
|
||||
|
||||
import SidebarLink from '../Link';
|
||||
import Section from '../Section';
|
||||
import { SeeMore } from '../SeeMore';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export const Locations = () => {
|
||||
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
|
||||
useNodes(locationsQuery.data?.nodes);
|
||||
const locations = useCache(locationsQuery.data?.items);
|
||||
const onlineLocations = useOnlineLocations();
|
||||
|
||||
return (
|
||||
<Section
|
||||
name="Locations"
|
||||
actionArea={
|
||||
<Link to="settings/library/locations">
|
||||
<SubtleButton />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<SeeMore>
|
||||
{locations?.map((location) => (
|
||||
<Location
|
||||
key={location.id}
|
||||
location={location}
|
||||
online={onlineLocations.some((l) => arraysEqual(location.pub_id, l))}
|
||||
/>
|
||||
))}
|
||||
</SeeMore>
|
||||
<AddLocationButton className="mt-1" />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Location = ({ location, online }: { location: LocationType; online: boolean }) => {
|
||||
const locationId = useMatch('/:libraryId/location/:locationId')?.params.locationId;
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const { isDroppable, className, setDroppableRef } = useExplorerDroppable({
|
||||
id: `sidebar-location-${location.id}`,
|
||||
allow: ['Path', 'NonIndexedPath', 'Object'],
|
||||
data: { type: 'location', path: '/', data: location },
|
||||
disabled: Number(locationId) === location.id && !path,
|
||||
navigateTo: `location/${location.id}`
|
||||
});
|
||||
|
||||
return (
|
||||
<ContextMenu locationId={location.id}>
|
||||
<SidebarLink
|
||||
ref={setDroppableRef}
|
||||
to={`location/${location.id}`}
|
||||
className={clsx(
|
||||
'border radix-state-open:border-accent',
|
||||
isDroppable ? ' border-accent' : 'border-transparent',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative mr-1 shrink-0 grow-0">
|
||||
<Icon name="Folder" size={18} />
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute bottom-0.5 right-0 h-1.5 w-1.5 rounded-full',
|
||||
online ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="truncate">{location.name}</span>
|
||||
</SidebarLink>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
103
interface/app/$libraryId/Layout/Sidebar/SavedSearches/index.tsx
Normal file
103
interface/app/$libraryId/Layout/Sidebar/SavedSearches/index.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
import { X } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { useMatch, useNavigate, useResolvedPath } from 'react-router';
|
||||
import { useLibraryMutation, useLibraryQuery, type SavedSearch } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
|
||||
import { Folder } from '~/components';
|
||||
|
||||
import SidebarLink from '../Link';
|
||||
import Section from '../Section';
|
||||
import { SeeMore } from '../SeeMore';
|
||||
|
||||
export const SavedSearches = () => {
|
||||
const savedSearches = useLibraryQuery(['search.saved.list']);
|
||||
|
||||
const path = useResolvedPath('saved-search/:id');
|
||||
const match = useMatch(path.pathname);
|
||||
const currentSearchId = match?.params?.id;
|
||||
|
||||
const currentIndex = currentSearchId
|
||||
? savedSearches.data?.findIndex((s) => s.id === Number(currentSearchId))
|
||||
: undefined;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const deleteSavedSearch = useLibraryMutation(['search.saved.delete'], {
|
||||
onSuccess() {
|
||||
if (currentIndex !== undefined && savedSearches.data) {
|
||||
const nextIndex = Math.min(currentIndex + 1, savedSearches.data.length - 2);
|
||||
|
||||
const search = savedSearches.data[nextIndex];
|
||||
|
||||
if (search) navigate(`saved-search/${search.id}`);
|
||||
else navigate(`./`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!savedSearches.data || savedSearches.data.length < 1) return null;
|
||||
|
||||
return (
|
||||
<Section
|
||||
name="Saved Searches"
|
||||
// actionArea={
|
||||
// <Link to="settings/library/saved-searches">
|
||||
// <SubtleButton />
|
||||
// </Link>
|
||||
// }
|
||||
>
|
||||
<SeeMore>
|
||||
{savedSearches.data.map((search, i) => (
|
||||
<SavedSearch
|
||||
key={search.id}
|
||||
search={search}
|
||||
onDelete={() => deleteSavedSearch.mutate(search.id)}
|
||||
/>
|
||||
))}
|
||||
</SeeMore>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const SavedSearch = ({ search, onDelete }: { search: SavedSearch; onDelete(): void }) => {
|
||||
const searchId = useMatch('/:libraryId/saved-search/:searchId')?.params.searchId;
|
||||
|
||||
const { isDroppable, className, setDroppableRef } = useExplorerDroppable({
|
||||
id: `sidebar-saved-search-${search.id}`,
|
||||
allow: ['Path', 'NonIndexedPath', 'Object'],
|
||||
disabled: Number(searchId) === search.id,
|
||||
navigateTo: `saved-search/${search.id}`
|
||||
});
|
||||
|
||||
return (
|
||||
<SidebarLink
|
||||
ref={setDroppableRef}
|
||||
to={`saved-search/${search.id}`}
|
||||
className={clsx(
|
||||
'group/button relative border border-transparent',
|
||||
isDroppable && '!cursor-no-drop',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative -mt-0.5 mr-1 shrink-0 grow-0">
|
||||
<Folder size={18} />
|
||||
</div>
|
||||
|
||||
<span className="truncate">{search.name}</span>
|
||||
|
||||
<Button
|
||||
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded-full shadow group-hover/button:block"
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<X size={10} weight="bold" className="text-ink-dull/50" />
|
||||
</Button>
|
||||
</SidebarLink>
|
||||
);
|
||||
};
|
|
@ -1,17 +1,14 @@
|
|||
import { Pencil, Plus, Trash } from '@phosphor-icons/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ContextMenu as CM, dialogManager } from '@sd/ui';
|
||||
import CreateDialog from '~/app/$libraryId/settings/library/tags/CreateDialog';
|
||||
import DeleteDialog from '~/app/$libraryId/settings/library/tags/DeleteDialog';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
tagId: number;
|
||||
}
|
||||
|
||||
export default ({ children, tagId }: Props) => {
|
||||
export const ContextMenu = ({ children, tagId }: PropsWithChildren<{ tagId: number }>) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<CM.Root trigger={children}>
|
||||
<CM.Item
|
67
interface/app/$libraryId/Layout/Sidebar/Tags/index.tsx
Normal file
67
interface/app/$libraryId/Layout/Sidebar/Tags/index.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import clsx from 'clsx';
|
||||
import { NavLink, useMatch } from 'react-router-dom';
|
||||
import { useCache, useLibraryQuery, useNodes, type Tag } from '@sd/client';
|
||||
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
|
||||
import { SubtleButton } from '~/components';
|
||||
|
||||
import SidebarLink from '../Link';
|
||||
import Section from '../Section';
|
||||
import { SeeMore } from '../SeeMore';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export const Tags = () => {
|
||||
const result = useLibraryQuery(['tags.list'], { keepPreviousData: true });
|
||||
useNodes(result.data?.nodes);
|
||||
const tags = useCache(result.data?.items);
|
||||
|
||||
if (!tags?.length) return null;
|
||||
|
||||
return (
|
||||
<Section
|
||||
name="Tags"
|
||||
actionArea={
|
||||
<NavLink to="settings/library/tags">
|
||||
<SubtleButton />
|
||||
</NavLink>
|
||||
}
|
||||
>
|
||||
<SeeMore>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag.id} tag={tag} />
|
||||
))}
|
||||
</SeeMore>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Tag = ({ tag }: { tag: Tag }) => {
|
||||
const tagId = useMatch('/:libraryId/tag/:tagId')?.params.tagId;
|
||||
|
||||
const { isDroppable, className, setDroppableRef } = useExplorerDroppable({
|
||||
id: `sidebar-tag-${tag.id}`,
|
||||
allow: ['Path', 'Object'],
|
||||
data: { type: 'tag', data: tag },
|
||||
navigateTo: `tag/${tag.id}`,
|
||||
disabled: Number(tagId) === tag.id
|
||||
});
|
||||
|
||||
return (
|
||||
<ContextMenu key={tag.id} tagId={tag.id}>
|
||||
<SidebarLink
|
||||
ref={setDroppableRef}
|
||||
to={`tag/${tag.id}`}
|
||||
className={clsx(
|
||||
'border radix-state-open:border-accent',
|
||||
isDroppable ? ' border-accent' : 'border-transparent',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="h-[12px] w-[12px] shrink-0 rounded-full"
|
||||
style={{ backgroundColor: tag.color || '#efefef' }}
|
||||
/>
|
||||
<span className="ml-1.5 truncate text-sm">{tag.name}</span>
|
||||
</SidebarLink>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
|
@ -23,8 +23,10 @@ import {
|
|||
} from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import { DragOverlay } from '../Explorer/DragOverlay';
|
||||
import { QuickPreviewContextProvider } from '../Explorer/QuickPreview/Context';
|
||||
import { LayoutContext } from './Context';
|
||||
import { DndContext } from './DndContext';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
const Layout = () => {
|
||||
|
@ -69,27 +71,32 @@ const Layout = () => {
|
|||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Sidebar />
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex w-full overflow-hidden',
|
||||
showControls.transparentBg ? 'bg-app/80' : 'bg-app'
|
||||
)}
|
||||
>
|
||||
{library ? (
|
||||
<QuickPreviewContextProvider>
|
||||
<LibraryContextProvider library={library}>
|
||||
<Suspense fallback={<div className="h-screen w-screen bg-app" />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</LibraryContextProvider>
|
||||
</QuickPreviewContextProvider>
|
||||
) : (
|
||||
<h1 className="p-4 text-white">
|
||||
Please select or create a library in the sidebar.
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<DndContext>
|
||||
<Sidebar />
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex w-full overflow-hidden',
|
||||
showControls.transparentBg ? 'bg-app/80' : 'bg-app'
|
||||
)}
|
||||
>
|
||||
{library ? (
|
||||
<QuickPreviewContextProvider>
|
||||
<LibraryContextProvider library={library}>
|
||||
<Suspense
|
||||
fallback={<div className="h-screen w-screen bg-app" />}
|
||||
>
|
||||
<Outlet />
|
||||
<DragOverlay />
|
||||
</Suspense>
|
||||
</LibraryContextProvider>
|
||||
</QuickPreviewContextProvider>
|
||||
) : (
|
||||
<h1 className="p-4 text-white">
|
||||
Please select or create a library in the sidebar.
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { createContext, useContext, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { SearchFilterArgs } from '@sd/client';
|
||||
|
||||
import TopBar from '.';
|
||||
import { getExplorerStore } from '../Explorer/store';
|
||||
|
||||
const TopBarContext = createContext<ReturnType<typeof useContextValue> | null>(null);
|
||||
|
||||
|
@ -33,6 +34,13 @@ function useContextValue() {
|
|||
export const Component = () => {
|
||||
const value = useContextValue();
|
||||
|
||||
// Reset drag state
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
getExplorerStore().drag = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TopBarContext.Provider value={value}>
|
||||
<TopBar />
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { ArrowLeft, ArrowRight } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Tooltip } from '@sd/ui';
|
||||
import { useKeyMatcher, useOperatingSystem, useShortcut } from '~/hooks';
|
||||
import { useRoutingContext } from '~/RoutingContext';
|
||||
|
||||
import { useExplorerDroppable } from '../Explorer/useExplorerDroppable';
|
||||
import TopBarButton from './TopBarButton';
|
||||
|
||||
export const NavigationButtons = () => {
|
||||
|
@ -17,6 +19,16 @@ export const NavigationButtons = () => {
|
|||
const canGoBack = currentIndex !== 0;
|
||||
const canGoForward = currentIndex !== maxIndex;
|
||||
|
||||
const droppableBack = useExplorerDroppable({
|
||||
navigateTo: -1,
|
||||
disabled: !canGoBack
|
||||
});
|
||||
|
||||
const droppableForward = useExplorerDroppable({
|
||||
navigateTo: 1,
|
||||
disabled: !canGoForward
|
||||
});
|
||||
|
||||
useShortcut('navBackwardHistory', () => {
|
||||
if (!canGoBack) return;
|
||||
navigate(-1);
|
||||
|
@ -49,9 +61,13 @@ export const NavigationButtons = () => {
|
|||
<Tooltip keybinds={[icon, '[']} label="Navigate back">
|
||||
<TopBarButton
|
||||
rounding="left"
|
||||
// className="text-[14px] text-ink-dull"
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={!canGoBack}
|
||||
ref={droppableBack.setDroppableRef}
|
||||
className={clsx(
|
||||
droppableBack.isDroppable && '!bg-app-selected',
|
||||
droppableBack.className
|
||||
)}
|
||||
>
|
||||
<ArrowLeft size={14} className="m-[4px]" weight="bold" />
|
||||
</TopBarButton>
|
||||
|
@ -59,9 +75,13 @@ export const NavigationButtons = () => {
|
|||
<Tooltip keybinds={[icon, ']']} label="Navigate forward">
|
||||
<TopBarButton
|
||||
rounding="right"
|
||||
// className="text-[14px] text-ink-dull"
|
||||
onClick={() => navigate(1)}
|
||||
disabled={!canGoForward}
|
||||
ref={droppableForward.setDroppableRef}
|
||||
className={clsx(
|
||||
droppableForward.isDroppable && '!bg-app-selected',
|
||||
droppableForward.className
|
||||
)}
|
||||
>
|
||||
<ArrowRight size={14} className="m-[4px]" weight="bold" />
|
||||
</TopBarButton>
|
||||
|
|
|
@ -15,7 +15,7 @@ import { NavigationButtons } from './NavigationButtons';
|
|||
|
||||
const TopBar = () => {
|
||||
const transparentBg = useShowControls().transparentBg;
|
||||
const { isDragging } = useExplorerStore();
|
||||
const { isDragSelecting } = useExplorerStore();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tabs = useTabsContext();
|
||||
|
@ -53,7 +53,7 @@ const TopBar = () => {
|
|||
className={clsx(
|
||||
'flex h-12 items-center gap-3.5 overflow-hidden px-3.5',
|
||||
'duration-250 transition-[background-color,border-color] ease-out',
|
||||
isDragging && 'pointer-events-none'
|
||||
isDragSelecting && 'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
|
|
@ -34,7 +34,7 @@ import {
|
|||
} from './Explorer/store';
|
||||
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
|
||||
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
|
||||
import { EmptyNotice } from './Explorer/View';
|
||||
import { EmptyNotice } from './Explorer/View/EmptyNotice';
|
||||
import { AddLocationButton } from './settings/library/locations/AddLocationButton';
|
||||
import { useTopBarContext } from './TopBar/Layout';
|
||||
import { TopBarPortal } from './TopBar/Portal';
|
||||
|
|
|
@ -34,7 +34,7 @@ import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Ex
|
|||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
import { useExplorerSearchParams } from '../Explorer/util';
|
||||
import { EmptyNotice } from '../Explorer/View';
|
||||
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
|
||||
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
|
||||
import SearchBar from '../Search/SearchBar';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
|
|
@ -18,7 +18,7 @@ import { usePathsExplorerQuery } from '../Explorer/queries';
|
|||
import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from '../Explorer/store';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
import { EmptyNotice } from '../Explorer/View';
|
||||
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
|
||||
import SearchOptions, { SearchContextProvider, useSearch, useSearchContext } from '../Search';
|
||||
import SearchBar from '../Search/SearchBar';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
|
|
@ -27,7 +27,7 @@ export function useAssignItemsToTag() {
|
|||
}
|
||||
});
|
||||
|
||||
return (tagId: number, items: AssignTagItems, unassign: boolean) => {
|
||||
return (tagId: number, items: AssignTagItems, unassign: boolean = false) => {
|
||||
const targets = items.map<Target>((item) => {
|
||||
if (item.type === 'Object') {
|
||||
return { Object: item.item.id };
|
||||
|
|
|
@ -10,7 +10,7 @@ import { useObjectsExplorerQuery } from '../Explorer/queries/useObjectsExplorerQ
|
|||
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from '../Explorer/store';
|
||||
import { DefaultTopBarOptions } from '../Explorer/TopBarOptions';
|
||||
import { useExplorer, useExplorerSettings } from '../Explorer/useExplorer';
|
||||
import { EmptyNotice } from '../Explorer/View';
|
||||
import { EmptyNotice } from '../Explorer/View/EmptyNotice';
|
||||
import SearchOptions, { SearchContextProvider, useSearch } from '../Search';
|
||||
import SearchBar from '../Search/SearchBar';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
|
|
|
@ -4,26 +4,25 @@ export * from './useClickOutside';
|
|||
export * from './useCounter';
|
||||
export * from './useDebouncedForm';
|
||||
export * from './useDismissibleNoticeStore';
|
||||
export * from './useDragSelect';
|
||||
export * from './useFocusState';
|
||||
export * from './useInputState';
|
||||
export * from './useIsDark';
|
||||
export * from './useKeyDeleteFile';
|
||||
export * from './useKeybindEventHandler';
|
||||
export * from './useKeybind';
|
||||
export * from './useKeybindEventHandler';
|
||||
export * from './useOperatingSystem';
|
||||
export * from './useScrolled';
|
||||
// export * from './useSearchStore';
|
||||
export * from './useIsLocationIndexing';
|
||||
export * from './useIsTextTruncated';
|
||||
export * from './useKeyCopyCutPaste';
|
||||
export * from './useKeyMatcher';
|
||||
export * from './useRedirectToNewLocation';
|
||||
export * from './useRouteTitle';
|
||||
export * from './useShortcut';
|
||||
export * from './useShowControls';
|
||||
export * from './useSpacedropState';
|
||||
export * from './useTheme';
|
||||
export * from './useWindowState';
|
||||
export * from './useZodRouteParams';
|
||||
export * from './useZodSearchParams';
|
||||
export * from './useIsTextTruncated';
|
||||
export * from './useKeyMatcher';
|
||||
export * from './useKeyCopyCutPaste';
|
||||
export * from './useRedirectToNewLocation';
|
||||
export * from './useWindowState';
|
||||
export * from './useIsLocationIndexing';
|
||||
export * from './useRouteTitle';
|
||||
|
|
|
@ -5,7 +5,8 @@ export const dismissibleNoticeStore = valtioPersist('dismissible-notice', {
|
|||
mediaView: false,
|
||||
gridView: false,
|
||||
listView: false,
|
||||
ephemeral: false
|
||||
ephemeral: false,
|
||||
ephemeralMoveFiles: false
|
||||
});
|
||||
|
||||
export function useDismissibleNoticeStore() {
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
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 };
|
|
@ -9,6 +9,8 @@
|
|||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/inter": "^4.5.15",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@icons-pack/react-simple-icons": "^9.1.0",
|
||||
|
@ -35,7 +37,6 @@
|
|||
"clsx": "^2.0.0",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dragselect": "^2.7.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"immer": "^10.0.3",
|
||||
"prismjs": "^1.29.0",
|
||||
|
|
BIN
packages/assets/icons/MoveLocation.png
Normal file
BIN
packages/assets/icons/MoveLocation.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 80 KiB |
BIN
packages/assets/icons/MoveLocation_Light.png
Normal file
BIN
packages/assets/icons/MoveLocation_Light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 64 KiB |
|
@ -121,6 +121,8 @@ import Mesh20 from './Mesh-20.png';
|
|||
import Mesh from './Mesh.png';
|
||||
import Mobile_Light from './Mobile_Light.png';
|
||||
import Mobile from './Mobile.png';
|
||||
import MoveLocation_Light from './MoveLocation_Light.png';
|
||||
import MoveLocation from './MoveLocation.png';
|
||||
import Movie_Light from './Movie_Light.png';
|
||||
import Movie from './Movie.png';
|
||||
import NewLocation from './NewLocation.png';
|
||||
|
@ -290,6 +292,8 @@ export {
|
|||
Mesh_Light,
|
||||
Mobile,
|
||||
Mobile_Light,
|
||||
MoveLocation,
|
||||
MoveLocation_Light,
|
||||
Movie,
|
||||
Movie_Light,
|
||||
NewLocation,
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import * as RDialog from '@radix-ui/react-dialog';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { iconNames } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import { ReactElement, ReactNode, useEffect } from 'react';
|
||||
import { FieldValues, UseFormHandleSubmit } from 'react-hook-form';
|
||||
|
@ -123,7 +122,7 @@ export interface DialogProps<S extends FieldValues>
|
|||
ctaDanger?: boolean;
|
||||
closeLabel?: string;
|
||||
cancelBtn?: boolean;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
onCancelled?: boolean | (() => void);
|
||||
submitDisabled?: boolean;
|
||||
transformOrigin?: string;
|
||||
|
@ -250,7 +249,7 @@ export function Dialog<S extends FieldValues>({
|
|||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex justify-end space-x-2 border-t border-app-line bg-app-input/60 p-3'
|
||||
'flex items-center justify-end space-x-2 border-t border-app-line bg-app-input/60 p-3'
|
||||
)}
|
||||
>
|
||||
{form.formState.isSubmitting && <Loader />}
|
||||
|
|
|
@ -51,7 +51,7 @@ importers:
|
|||
version: 5.2.2
|
||||
vite:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0(@types/node@18.17.19)
|
||||
version: 4.5.0(less@4.2.0)
|
||||
|
||||
.github/actions/publish-artifacts:
|
||||
dependencies:
|
||||
|
@ -626,7 +626,7 @@ importers:
|
|||
version: 5.2.2
|
||||
vite:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0(@types/node@18.17.19)
|
||||
version: 4.5.0(less@4.2.0)
|
||||
vite-plugin-html:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0(vite@4.5.0)
|
||||
|
@ -639,6 +639,12 @@ importers:
|
|||
|
||||
interface:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@18.2.0)
|
||||
'@fontsource/inter':
|
||||
specifier: ^4.5.15
|
||||
version: 4.5.15
|
||||
|
@ -717,9 +723,6 @@ importers:
|
|||
dayjs:
|
||||
specifier: ^1.11.10
|
||||
version: 1.11.10
|
||||
dragselect:
|
||||
specifier: ^2.7.4
|
||||
version: 2.7.4
|
||||
framer-motion:
|
||||
specifier: ^10.16.4
|
||||
version: 10.16.4(react-dom@18.2.0)(react@18.2.0)
|
||||
|
@ -4852,6 +4855,37 @@ packages:
|
|||
engines: {node: '>=10.0.0'}
|
||||
dev: true
|
||||
|
||||
/@dnd-kit/accessibility@3.1.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/@dnd-kit/core@6.1.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.0(react@18.2.0)
|
||||
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/@dnd-kit/utilities@3.2.2(react@18.2.0):
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/@docsearch/css@3.5.2:
|
||||
resolution: {integrity: sha512-SPiDHaWKQZpwR2siD0KQUwlStvIAnEyK6tAE2h2Wuoq8ue9skzhlyVQ1ddzOxX6khULnAALDiR/isSF3bnuciA==}
|
||||
dev: false
|
||||
|
@ -10854,7 +10888,7 @@ packages:
|
|||
'@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.2)
|
||||
'@types/babel__core': 7.20.3
|
||||
react-refresh: 0.14.0
|
||||
vite: 4.5.0(sass@1.69.5)
|
||||
vite: 4.5.0(@types/node@18.17.19)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
@ -13126,10 +13160,6 @@ packages:
|
|||
resolution: {integrity: sha512-+3NaRjWktb5r61ZFoDejlykPEFKT5N/LkbXsaddlw6xNSXBanUYpFc2AXXpbJDilPHazcSreU/DpQIaxfX0NfQ==}
|
||||
dev: false
|
||||
|
||||
/dragselect@2.7.4:
|
||||
resolution: {integrity: sha512-j0qFl4xvsyImlSTn9erDCCT4SSPUMssgKAYuKqhsPr8WPphLghHfjDd4WR2jLjL91fTQQlbbIJ/7T2qwD2hghQ==}
|
||||
dev: false
|
||||
|
||||
/duplexer@0.1.2:
|
||||
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
|
||||
dev: true
|
||||
|
@ -23307,7 +23337,7 @@ packages:
|
|||
'@rollup/pluginutils': 5.0.5
|
||||
'@svgr/core': 8.1.0(typescript@5.2.2)
|
||||
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0)
|
||||
vite: 4.5.0(sass@1.69.5)
|
||||
vite: 4.5.0(@types/node@18.17.19)
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
|
Loading…
Reference in a new issue