From 677e1b63e914ec7cf6f8df689f96b751fb262e8b Mon Sep 17 00:00:00 2001 From: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Date: Fri, 24 Feb 2023 22:16:57 -0800 Subject: [PATCH] [ENG-379] Explorer item resizer (#580) * added item resizer fixed explorer store bug refactored file image component * better sizing for videos * fixed inspector width issue * remove console.log * added column titles to list view + extra details * moved util * remove imports * Update packages/interface/src/components/explorer/FileColumns.tsx Co-authored-by: Brendan Allan * address issues * fix extension in file list name * Update packages/interface/src/components/explorer/FileColumns.tsx --------- Co-authored-by: Brendan Allan --- core/src/api/locations.rs | 14 +- core/src/object/fs/decrypt.rs | 4 +- core/src/object/fs/encrypt.rs | 4 +- core/src/object/fs/mod.rs | 2 + .../explorer/ExplorerContextMenu.tsx | 12 +- .../explorer/ExplorerOptionsPanel.tsx | 15 +- .../components/explorer/ExplorerTopBar.tsx | 9 +- .../src/components/explorer/FileColumns.tsx | 42 ++++++ .../src/components/explorer/FileItem.tsx | 42 ++---- .../src/components/explorer/FileRow.tsx | 116 +++++++-------- .../src/components/explorer/FileThumb.tsx | 132 ++++++++++++------ .../src/components/explorer/Inspector.tsx | 15 +- .../components/explorer/VirtualizedList.tsx | 64 +++------ .../interface/src/components/explorer/util.ts | 13 ++ .../interface/src/hooks/useExplorerStore.tsx | 8 +- 15 files changed, 284 insertions(+), 208 deletions(-) create mode 100644 packages/interface/src/components/explorer/FileColumns.tsx create mode 100644 packages/interface/src/components/explorer/util.ts diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index 164e4aa54..58ca0d094 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -118,18 +118,6 @@ pub(crate) fn mount() -> impl RouterBuilderLike { .exec() .await?; - // library - // .queue_job(Job::new( - // ThumbnailJobInit { - // location_id: location.id, - // // recursive: false, // TODO: do this - // root_path: PathBuf::from(&directory.materialized_path), - // background: true, - // }, - // ThumbnailJob {}, - // )) - // .await; - let mut items = Vec::with_capacity(file_paths.len()); for file_path in file_paths { @@ -223,7 +211,7 @@ pub(crate) fn mount() -> impl RouterBuilderLike { async_stream::stream! { let online = location_manager.get_online().await; - dbg!(&online); + // dbg!(&online); yield online; while let Ok(locations) = rx.recv().await { diff --git a/core/src/object/fs/decrypt.rs b/core/src/object/fs/decrypt.rs index 5f036c7e2..3dbcc2a80 100644 --- a/core/src/object/fs/decrypt.rs +++ b/core/src/object/fs/decrypt.rs @@ -9,7 +9,7 @@ use tokio::fs::File; use crate::job::{JobError, JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext}; -use super::{context_menu_fs_info, FsInfo, BYTES}; +use super::{context_menu_fs_info, FsInfo, BYTES_EXT}; pub struct FileDecryptorJob; #[derive(Serialize, Deserialize, Debug)] pub struct FileDecryptorJobState {} @@ -74,7 +74,7 @@ impl StatefulJob for FileDecryptorJob { || { let mut path = info.fs_path.clone(); let extension = path.extension().map_or("decrypted", |ext| { - if ext == BYTES { + if ext == BYTES_EXT { "" } else { "decrypted" diff --git a/core/src/object/fs/encrypt.rs b/core/src/object/fs/encrypt.rs index efdd9997d..b5b096fdb 100644 --- a/core/src/object/fs/encrypt.rs +++ b/core/src/object/fs/encrypt.rs @@ -15,7 +15,7 @@ use specta::Type; use tokio::{fs::File, io::AsyncReadExt}; use tracing::warn; -use super::{context_menu_fs_info, FsInfo}; +use super::{context_menu_fs_info, FsInfo, BYTES_EXT}; pub struct FileEncryptorJob; @@ -108,7 +108,7 @@ impl StatefulJob for FileEncryptorJob { "path contents when converted to string", ), })? - .to_string() + ".bytes", + .to_string() + BYTES_EXT, ) }, )?; diff --git a/core/src/object/fs/mod.rs b/core/src/object/fs/mod.rs index 1e2d068fb..436c383dc 100644 --- a/core/src/object/fs/mod.rs +++ b/core/src/object/fs/mod.rs @@ -30,6 +30,8 @@ pub enum ObjectType { Directory, } +pub const BYTES_EXT: &str = ".bytes"; + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct FsInfo { pub path_data: file_path_with_object::Data, diff --git a/packages/interface/src/components/explorer/ExplorerContextMenu.tsx b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx index 2026103a5..f9d6cef0b 100644 --- a/packages/interface/src/components/explorer/ExplorerContextMenu.tsx +++ b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import { ArrowBendUpRight, Clipboard, @@ -209,11 +210,16 @@ export function ExplorerContextMenu(props: PropsWithChildren) { ); } -export interface FileItemContextMenuProps extends PropsWithChildren { +export interface ExplorerItemContextMenuProps extends PropsWithChildren { data: ExplorerItem; + className?: string; } -export function FileItemContextMenu({ data, ...props }: FileItemContextMenuProps) { +export function ExplorerItemContextMenu({ + data, + className, + ...props +}: ExplorerItemContextMenuProps) { const { library } = useLibraryContext(); const store = useExplorerStore(); const params = useExplorerParams(); @@ -227,7 +233,7 @@ export function FileItemContextMenu({ data, ...props }: FileItemContextMenuProps const copyFiles = useLibraryMutation('files.copyFiles'); return ( -
+
{/* Explorer Appearance */} Item size - + { + getExplorerStore().gridItemSize = value[0] || 100; + console.log({ value: value, gridItemSize: explorerStore.gridItemSize }); + }} + defaultValue={[explorerStore.gridItemSize]} + max={200} + step={10} + min={60} + />
Sort by diff --git a/packages/interface/src/components/explorer/ExplorerTopBar.tsx b/packages/interface/src/components/explorer/ExplorerTopBar.tsx index 1084ec09f..2446742d3 100644 --- a/packages/interface/src/components/explorer/ExplorerTopBar.tsx +++ b/packages/interface/src/components/explorer/ExplorerTopBar.tsx @@ -37,7 +37,7 @@ export interface TopBarButtonProps { // export const TopBarIcon = (icon: any) => tw(icon)`m-0.5 w-5 h-5 text-ink-dull`; const topBarButtonStyle = cva( - 'text-ink hover:text-ink text-md hover:bg-app-selected radix-state-open:bg-app-selected mr-[1px] flex border-none p-0.5 font-medium outline-none transition-colors duration-100', + 'text-ink hover:text-ink text-md hover:bg-app-selected radix-state-open:bg-app-selected mr-[1px] flex border-none !p-0.5 font-medium outline-none transition-colors duration-100', { variants: { active: { @@ -63,7 +63,12 @@ const TOP_BAR_ICON_STYLE = 'm-0.5 w-5 h-5 text-ink-dull'; const TopBarButton = forwardRef( ({ active, rounding, className, ...props }, ref) => { return ( - ); diff --git a/packages/interface/src/components/explorer/FileColumns.tsx b/packages/interface/src/components/explorer/FileColumns.tsx new file mode 100644 index 000000000..081d7317d --- /dev/null +++ b/packages/interface/src/components/explorer/FileColumns.tsx @@ -0,0 +1,42 @@ +export interface IColumn { + column: string; + key: string; + width: number; +} + +export const LIST_VIEW_HEADER_HEIGHT = 40; + +// Function ensure no types are lost, but guarantees that they are Column[] +export function ensureIsColumns(data: T) { + return data; +} + +export const columns = [ + { column: 'Name', key: 'name', width: 280 }, + // { column: 'Size', key: 'size_in_bytes', width: 120 }, + { column: 'Type', key: 'extension', width: 150 }, + { column: 'Size', key: 'size', width: 100 }, + { column: 'Date Created', key: 'date_created', width: 150 }, + { column: 'Content ID', key: 'cas_id', width: 150 } +] as const satisfies Readonly; + +export type ColumnKey = (typeof columns)[number]['key']; + +export function ListViewHeader() { + return ( +
+ {columns.map((col) => ( +
+ {col.column} +
+ ))} +
+ ); +} diff --git a/packages/interface/src/components/explorer/FileItem.tsx b/packages/interface/src/components/explorer/FileItem.tsx index 3105b12b3..581a2567c 100644 --- a/packages/interface/src/components/explorer/FileItem.tsx +++ b/packages/interface/src/components/explorer/FileItem.tsx @@ -2,9 +2,9 @@ import clsx from 'clsx'; import { HTMLAttributes } from 'react'; import { ExplorerItem, ObjectKind, isObject } from '@sd/client'; import { cva, tw } from '@sd/ui'; -import { getExplorerStore } from '~/hooks/useExplorerStore'; -import { FileItemContextMenu } from './ExplorerContextMenu'; -import FileThumb from './FileThumb'; +import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore'; +import { ExplorerItemContextMenu } from './ExplorerContextMenu'; +import { FileThumb } from './FileThumb'; const NameArea = tw.div`flex justify-center`; @@ -30,8 +30,10 @@ function FileItem({ data, selected, index, ...rest }: Props) { const isVid = ObjectKind[objectData?.kind || 0] === 'Video'; const item = data.item; + const explorerStore = useExplorerStore(); + return ( - +
{ if (index != undefined) { @@ -40,40 +42,22 @@ function FileItem({ data, selected, index, ...rest }: Props) { }} {...rest} draggable - className={clsx('mb-3 inline-block w-[100px]', rest.className)} + style={{ width: explorerStore.gridItemSize }} + className={clsx('mb-3 inline-block', rest.className)} >
-
- - {item.extension && isVid && ( -
- {item.extension} -
- )} -
+
@@ -82,7 +66,7 @@ function FileItem({ data, selected, index, ...rest }: Props) {
-
+ ); } diff --git a/packages/interface/src/components/explorer/FileRow.tsx b/packages/interface/src/components/explorer/FileRow.tsx index 81fa930a7..3ff646061 100644 --- a/packages/interface/src/components/explorer/FileRow.tsx +++ b/packages/interface/src/components/explorer/FileRow.tsx @@ -1,7 +1,14 @@ +import byteSize from 'byte-size'; import clsx from 'clsx'; +import dayjs from 'dayjs'; import { HTMLAttributes } from 'react'; -import { ExplorerItem } from '@sd/client'; -import FileThumb from './FileThumb'; +import { ExplorerItem, ObjectKind, isObject, isPath } from '@sd/client'; +import { getExplorerStore } from '../../hooks/useExplorerStore'; +import { ExplorerItemContextMenu } from './ExplorerContextMenu'; +import { ColumnKey, columns } from './FileColumns'; +import { FileThumb } from './FileThumb'; +import { InfoPill } from './Inspector'; +import { getExplorerItemData } from './util'; interface Props extends HTMLAttributes { data: ExplorerItem; @@ -11,24 +18,26 @@ interface Props extends HTMLAttributes { function FileRow({ data, index, selected, ...props }: Props) { return ( -
- {columns.map((col) => ( -
- -
- ))} -
+ +
+ {columns.map((col) => ( +
+ +
+ ))} +
+
); } @@ -36,32 +45,44 @@ const RenderCell: React.FC<{ colKey: ColumnKey; data: ExplorerItem; }> = ({ colKey, data }) => { + const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; + const { cas_id } = getExplorerItemData(data); + switch (colKey) { case 'name': return (
-
- +
+
- {/* {colKey == 'name' && - (() => { - switch (row.extension.toLowerCase()) { - case 'mov' || 'mp4': - return ; - - default: - if (row.is_dir) - return ; - return ; - } - })()} */} - {data.item[colKey]} + + {data.item.name} + {data.item.extension && `.${data.item.extension}`} +
); - // case 'size_in_bytes': - // return {byteSize(Number(value || 0))}; + case 'size': + return ( + + {byteSize(Number(objectData?.size_in_bytes || 0)).toString()} + + ); + case 'date_created': + return ( + + {dayjs(data.item?.date_created).format('MMM Do YYYY')} + + ); + case 'cas_id': + return {cas_id}; case 'extension': - return {data.item[colKey]}; + return ( +
+ + {isPath(data) && data.item.is_dir ? 'Folder' : ObjectKind[objectData?.kind || 0]} + +
+ ); // case 'meta_integrity_hash': // return {value}; // case 'tags': @@ -72,23 +93,4 @@ const RenderCell: React.FC<{ } }; -interface IColumn { - column: string; - key: string; - width: number; -} - -// Function ensure no types are lost, but guarantees that they are Column[] -function ensureIsColumns(data: T) { - return data; -} - -const columns = ensureIsColumns([ - { column: 'Name', key: 'name', width: 280 } as const, - // { column: 'Size', key: 'size_in_bytes', width: 120 } as const, - { column: 'Type', key: 'extension', width: 100 } as const -]); - -type ColumnKey = (typeof columns)[number]['key']; - export default FileRow; diff --git a/packages/interface/src/components/explorer/FileThumb.tsx b/packages/interface/src/components/explorer/FileThumb.tsx index c160df02c..501b03ac1 100644 --- a/packages/interface/src/components/explorer/FileThumb.tsx +++ b/packages/interface/src/components/explorer/FileThumb.tsx @@ -6,62 +6,114 @@ import Executable from '@sd/assets/images/Executable.png'; import File from '@sd/assets/images/File.png'; import Video from '@sd/assets/images/Video.png'; import clsx from 'clsx'; -import { ExplorerItem, isObject, isPath } from '@sd/client'; -import { useExplorerStore } from '~/hooks/useExplorerStore'; +import { CSSProperties } from 'react'; +import { ExplorerItem } from '@sd/client'; import { usePlatform } from '~/util/Platform'; import { Folder } from '../icons/Folder'; +import { getExplorerItemData } from './util'; -interface Props { +// const icons = import.meta.glob('../../../../assets/icons/*.svg'); +interface FileItemProps { data: ExplorerItem; size: number; className?: string; - style?: React.CSSProperties; - iconClassNames?: string; - kind?: string; } -// const icons = import.meta.glob('../../../../assets/icons/*.svg'); +export function FileThumb({ data, size, className }: FileItemProps) { + const { cas_id, isDir, kind, hasThumbnail, extension } = getExplorerItemData(data); -export default function FileThumb({ data, ...props }: Props) { + // 10 percent of the size + const videoBarsHeight = Math.floor(size / 10); + + // calculate 16:9 ratio for height from size + const videoHeight = Math.floor((size * 9) / 16) + videoBarsHeight * 2; + + return ( +
+ 60 && 'border-app-line border-2', + kind === 'Video' && 'rounded border-x-0 !border-black' + )} + imgStyle={ + kind === 'Video' + ? { + borderTopWidth: videoBarsHeight, + borderBottomWidth: videoBarsHeight, + width: size, + height: videoHeight + } + : {} + } + /> + {extension && kind === 'Video' && size > 80 && ( +
+ {extension} +
+ )} +
+ ); +} +interface FileThumbImgProps { + isDir: boolean; + cas_id: string | null; + kind: string | null; + extension: string | null; + size: number; + hasThumbnail: boolean; + imgClassName?: string; + imgStyle?: CSSProperties; +} + +export function FileThumbImg({ + isDir, + cas_id, + kind, + size, + hasThumbnail, + extension, + imgClassName, + imgStyle +}: FileThumbImgProps) { const platform = usePlatform(); - // const Icon = useMemo(() => { - // const icon = icons[`../../../../assets/icons/${item.extension}.svg`]; - // const Icon = icon - // ? lazy(() => icon().then((v) => ({ default: (v as any).ReactComponent }))) - // : undefined; - // return Icon; - // }, [item.extension]); + if (isDir) return ; - if (isPath(data) && data.item.is_dir) return ; + if (!cas_id) return
; + const url = platform.getThumbnailUrlById(cas_id); - if (data.has_thumbnail) { - const cas_id = isObject(data) ? data.item.file_paths[0]?.cas_id : data.item.cas_id; - - if (!cas_id) return
; - - const url = platform.getThumbnailUrlById(cas_id); - - if (url) - return ( - - ); + if (url && hasThumbnail) { + return ( + + ); } let icon = File; // Hacky (and temporary) way to integrate thumbnails - if (props.kind === 'Archive') icon = Archive; - else if (props.kind === 'Video') icon = Video; - else if (props.kind === 'Document' && data.item.extension === 'pdf') icon = DocumentPdf; - else if (props.kind === 'Executable') icon = Executable; - else if (props.kind === 'Encrypted') icon = Encrypted; - else if (props.kind === 'Compressed') icon = Compressed; + if (kind === 'Archive') icon = Archive; + else if (kind === 'Video') icon = Video; + else if (kind === 'Document' && extension === 'pdf') icon = DocumentPdf; + else if (kind === 'Executable') icon = Executable; + else if (kind === 'Encrypted') icon = Encrypted; + else if (kind === 'Compressed') icon = Compressed; - return ; + return ; } diff --git a/packages/interface/src/components/explorer/Inspector.tsx b/packages/interface/src/components/explorer/Inspector.tsx index ef150976c..ff2d36dda 100644 --- a/packages/interface/src/components/explorer/Inspector.tsx +++ b/packages/interface/src/components/explorer/Inspector.tsx @@ -14,7 +14,7 @@ import { import { Button, tw } from '@sd/ui'; import { DefaultProps } from '../primitive/types'; import { Tooltip } from '../tooltip/Tooltip'; -import FileThumb from './FileThumb'; +import { FileThumb } from './FileThumb'; import { Divider } from './inspector/Divider'; import FavoriteButton from './inspector/FavoriteButton'; import Note from './inspector/Note'; @@ -80,17 +80,10 @@ export const Inspector = ({ data, context, ...elementProps }: Props) => { <>
- +

@@ -126,7 +119,7 @@ export const Inspector = ({ data, context, ...elementProps }: Props) => {
{isDir ? 'Folder' : ObjectKind[objectData?.kind || 0]} - {item && {item.extension}} + {item?.extension && {item.extension}} {tags?.data?.map((tag) => ( { }, [explorerStore.showInspector]); // sizing calculations - const amountOfColumns = Math.floor(width / explorerStore.gridItemSize) || 8, + const GRID_TEXT_AREA_HEIGHT = explorerStore.gridItemSize / 4; + const amountOfColumns = Math.floor(width / explorerStore.gridItemSize) || 4, amountOfRows = explorerStore.layoutMode === 'grid' ? Math.ceil(data.length / amountOfColumns) : data.length, itemSize = @@ -92,28 +94,6 @@ export const VirtualizedList = memo(({ data, context, onScroll }: Props) => { getExplorerStore().selectedRowIndex = explorerStore.selectedRowIndex + 1; }); - // const Header = () => ( - //
- // {props.context.name && ( - //

{props.context.name}

- // )} - //
- //
- // {columns.map((col) => ( - //
- // - // {col.column} - //
- // ))} - //
- //
- //
- // ); - return (
{
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => (
{ className="absolute top-0 left-0 flex w-full" key={virtualRow.key} > - {explorerStore.layoutMode === 'list' ? ( + {explorerStore.layoutMode === 'list' && ( - ) : ( + )} + {explorerStore.layoutMode === 'grid' && [...Array(amountOfColumns)].map((_, i) => { const index = virtualRow.index * amountOfColumns + i; const item = data[index]; const isSelected = explorerStore.selectedRowIndex === index; return ( -
-
- {item && ( - - )} -
+
+ {item && ( + + )}
); - }) - )} + })}
))}
@@ -194,7 +173,6 @@ const WrappedItem = memo(({ item, index, isSelected, kind }: WrappedItemProps) = const onClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - getExplorerStore().selectedRowIndex = isSelected ? -1 : index; }, [isSelected, index] diff --git a/packages/interface/src/components/explorer/util.ts b/packages/interface/src/components/explorer/util.ts new file mode 100644 index 000000000..bbe45b281 --- /dev/null +++ b/packages/interface/src/components/explorer/util.ts @@ -0,0 +1,13 @@ +import { ExplorerItem, ObjectKind, isObject, isPath } from '@sd/client'; + +export function getExplorerItemData(data: ExplorerItem) { + const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; + + return { + cas_id: (isObject(data) ? data.item.file_paths[0]?.cas_id : data.item.cas_id) || null, + isDir: isPath(data) && data.item.is_dir, + kind: ObjectKind[objectData?.kind || 0] || null, + hasThumbnail: data.has_thumbnail, + extension: data.item.extension + }; +} diff --git a/packages/interface/src/hooks/useExplorerStore.tsx b/packages/interface/src/hooks/useExplorerStore.tsx index 0f3687a12..2e36f6401 100644 --- a/packages/interface/src/hooks/useExplorerStore.tsx +++ b/packages/interface/src/hooks/useExplorerStore.tsx @@ -52,11 +52,11 @@ const explorerStore = proxy({ }); export function useExplorerStore() { - const { library } = useLibraryContext(); + // const { library } = useLibraryContext(); - useEffect(() => { - explorerStore.reset(); - }, [library.uuid]); + // useEffect(() => { + // explorerStore.reset(); + // }, [library.uuid]); return useSnapshot(explorerStore); }