From a9a6e4b23decee827da1c626a9e475e3d96f9b90 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 4 Oct 2023 16:37:02 +0100 Subject: [PATCH] feat: preview pane + better row selection handling (#12) --- app/bucket/[bucket]/[[...path]]/layout.tsx | 10 +- app/bucket/[bucket]/[[...path]]/page.tsx | 24 +-- app/layout.tsx | 20 ++- components/icons/index.ts | 2 + components/index.ts | 1 + components/navs/top-nav.tsx | 3 + components/object-explorer/explorer.tsx | 169 +++++++++++------- components/object-explorer/index.ts | 2 + .../object-explorer/object-preview-inner.tsx | 63 +++++++ components/object-explorer/object-preview.tsx | 128 +++++++++++++ components/object-explorer/object-row.tsx | 14 +- components/object-explorer/preview-pane.tsx | 69 +++++++ .../providers/explorer-events-provider.tsx | 40 +---- .../providers/file-preview-provider.tsx | 158 ---------------- components/providers/index.ts | 2 +- .../providers/object-explorer-provider.tsx | 53 ++++-- components/providers/settings-provider.tsx | 47 +++++ components/tab-group.tsx | 35 ++++ components/typography/header.tsx | 18 ++ components/typography/index.ts | 1 + utils/file-object.ts | 3 + utils/hooks/use-local-storage.ts | 19 ++ utils/index.ts | 4 +- 23 files changed, 573 insertions(+), 312 deletions(-) create mode 100644 components/object-explorer/object-preview-inner.tsx create mode 100644 components/object-explorer/object-preview.tsx create mode 100644 components/object-explorer/preview-pane.tsx delete mode 100644 components/providers/file-preview-provider.tsx create mode 100644 components/providers/settings-provider.tsx create mode 100644 components/tab-group.tsx create mode 100644 components/typography/header.tsx create mode 100644 utils/hooks/use-local-storage.ts diff --git a/app/bucket/[bucket]/[[...path]]/layout.tsx b/app/bucket/[bucket]/[[...path]]/layout.tsx index df186d7..9985f18 100644 --- a/app/bucket/[bucket]/[[...path]]/layout.tsx +++ b/app/bucket/[bucket]/[[...path]]/layout.tsx @@ -1,6 +1,6 @@ import { validateBucketName } from '@/utils/cf'; import { formatBucketName, formatFullPath } from '@/utils'; -import { ObjectExplorerProvider, FilePreviewProvider, ExplorerEventsProvider } from '@/components'; +import { ObjectExplorerProvider, ExplorerEventsProvider, ObjectPreview } from '@/components'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { Ctx } from './ctx'; @@ -21,9 +21,11 @@ const Layout = async ({ params: { bucket, path }, children }: Props): Promise - - {children} - + + {children} + + + ); diff --git a/app/bucket/[bucket]/[[...path]]/page.tsx b/app/bucket/[bucket]/[[...path]]/page.tsx index fa21f16..8e86f28 100644 --- a/app/bucket/[bucket]/[[...path]]/page.tsx +++ b/app/bucket/[bucket]/[[...path]]/page.tsx @@ -1,6 +1,6 @@ import { getBucketItems } from '@/utils/cf'; import { formatFullPath } from '@/utils'; -import { ObjectExplorer } from '@/components'; +import { ObjectExplorer, PreviewPane } from '@/components'; import type { RouteParams } from './layout'; type Props = { params: RouteParams }; @@ -12,15 +12,19 @@ const Page = async ({ params: { bucket, path } }: Props) => { const objects = [...items.delimitedPrefixes, ...items.objects]; return ( -
- {items.delimitedPrefixes.length === 0 && items.objects.length === 0 ? ( - No items found... - ) : ( - - )} +
+
+ {items.delimitedPrefixes.length === 0 && items.objects.length === 0 ? ( + No items found... + ) : ( + + )} +
+ +
); }; diff --git a/app/layout.tsx b/app/layout.tsx index e3f64bb..731b601 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import localFont from 'next/font/local'; -import { LocationProvider, ThemeProvider, SideNav, TopNav } from '@/components'; +import { LocationProvider, ThemeProvider, SideNav, TopNav, SettingsProvider } from '@/components'; import './globals.css'; import { getBuckets } from '@/utils/cf'; import { AuthProvider } from '@/components/providers/auth-provider'; @@ -51,17 +51,19 @@ const Layout = async ({ children }: Props) => { - -
- + + +
+ -
- +
+ - {children} + {children} +
-
-
+ +
diff --git a/components/icons/index.ts b/components/icons/index.ts index 5b6ffe3..681b367 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -26,4 +26,6 @@ export { GearSix, DotsThreeOutlineVertical, CaretCircleDown, + Door, + DoorOpen, } from '@phosphor-icons/react'; diff --git a/components/index.ts b/components/index.ts index 97824db..76fa3d9 100644 --- a/components/index.ts +++ b/components/index.ts @@ -4,3 +4,4 @@ export * from './typography'; export * from './navs'; export * from './file-upload'; export * from './object-explorer'; +export * from './tab-group'; diff --git a/components/navs/top-nav.tsx b/components/navs/top-nav.tsx index 0c715c7..5dd0697 100644 --- a/components/navs/top-nav.tsx +++ b/components/navs/top-nav.tsx @@ -7,6 +7,7 @@ import { addLeadingSlash, formatFullPath, formatBucketName, toTitleCase } from ' import { ThemeToggle, UploadFilesProvider } from '../providers'; import { ArrowLeft, ArrowRight, CaretRight } from '../icons'; import { UploadFileButton } from '../file-upload'; +import { TogglePreviewPaneButton } from '../object-explorer'; const TopNavSection = ({ children }: { children: React.ReactNode }): JSX.Element => (
{children}
@@ -79,6 +80,8 @@ export const TopNav = (): JSX.Element => { + + diff --git a/components/object-explorer/explorer.tsx b/components/object-explorer/explorer.tsx index 3cd132b..e3ad130 100644 --- a/components/object-explorer/explorer.tsx +++ b/components/object-explorer/explorer.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { twMerge } from 'tailwind-merge'; -import type { ColumnDef, Row, SortingState } from '@tanstack/react-table'; +import type { ColumnDef, SortingState } from '@tanstack/react-table'; import { flexRender, getCoreRowModel, @@ -10,15 +10,15 @@ import { useReactTable, } from '@tanstack/react-table'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { parseObject } from '@/utils'; +import { parseObject, type FileObject } from '@/utils'; import { useOnClickOutside } from '@/utils/hooks'; import { alphanumeric } from '@/utils/compare-alphanumeric-patched'; import { getFileIcon, getSortIcon } from './file-icons'; import { ObjectRow } from './object-row'; -import { useExplorerEvents, useFilePreview, useObjectExplorer } from '../providers'; +import { useExplorerEvents, useObjectExplorer } from '../providers'; type Props = { - initialObjects: (R2Object | string)[]; + initialObjects: (string | R2Object)[]; initialCursor?: string; }; @@ -27,25 +27,38 @@ type Props = { export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.Element => { const parentRef = useRef(null); - const { isFilePreviewActive } = useFilePreview(); + const parsedInitialObjects = useMemo( + () => initialObjects.map((o) => parseObject(o)), + [initialObjects], + ); const { - objects = initialObjects, + objects = parsedInitialObjects, selectedObjects, addSelectedObject, removeSelectedObject, updateObjects, tryFetchMoreObjects, clearSelectedObjects, + isPreviewActive, } = useObjectExplorer(); useLayoutEffect( - () => updateObjects(initialObjects, { clear: true, cursor: initialCursor }), - [updateObjects, initialObjects, initialCursor], + () => updateObjects(parsedInitialObjects, { clear: true, cursor: initialCursor }), + [updateObjects, parsedInitialObjects, initialCursor], ); // disable if the file is currently being previewed so that we don't lose the focus. - useOnClickOutside(parentRef, clearSelectedObjects, isFilePreviewActive); + useOnClickOutside( + parentRef, + (e) => { + // check if target element is child of preview pane and don't clear selection if it is. + if (!document.getElementById('preview-pane')?.contains(e.target as Node)) { + clearSelectedObjects(); + } + }, + isPreviewActive, + ); const handleScroll = useCallback( (e: React.UIEvent) => { @@ -57,7 +70,7 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El [tryFetchMoreObjects], ); - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { header: 'Name', @@ -65,9 +78,9 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El enableSorting: true, // @ts-expect-error - Typescript doesn't know it's a custom sorting function. sortingFn: 'alphanumeric_foldersTop', - accessorFn: (object) => parseObject(object).getName(), + accessorFn: (object) => object.getName(), cell: (cell) => { - const Icon = getFileIcon(parseObject(cell.row.original).getType()); + const Icon = getFileIcon(cell.row.original.getType()); const value = cell.getValue(); return ( @@ -86,7 +99,7 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El header: 'Type', enableSorting: true, sortingFn: 'text', - accessorFn: (object) => parseObject(object).getType(), + accessorFn: (object) => object.getType(), cell: (cell) => ( {cell.getValue()} @@ -97,14 +110,14 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El header: 'Size', enableSorting: true, sortingFn: 'basic', - accessorFn: (object) => (parseObject(object).isDirectory ? 0 : (object as R2Object).size), - cell: (cell) => {parseObject(cell.row.original).getSize()}, + accessorFn: (object) => object.getSize(), + cell: (cell) => {cell.getValue()}, }, { header: 'Last Modified', enableSorting: true, sortingFn: 'datetime', - accessorFn: (object) => parseObject(object).getLastModified(), + accessorFn: (object) => object.getLastModified(), cell: (cell) => ( {cell.getValue()?.toLocaleDateString() ?? ''} @@ -163,78 +176,60 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El const { handleDoubleClick } = useExplorerEvents(); - const handleKeyPress = useCallback( - (e: KeyboardEvent) => { - if (isFilePreviewActive) return; + const selectNextObject = useCallback( + (opts: { up?: boolean; down?: boolean; shiftKey?: boolean }) => { + const selectedObjs = [...selectedObjects.entries()]; - const getFirstSelected = () => selectedObjects.values().next().value as string; - const getLastSelected = () => [...selectedObjects.values()].reverse()[0] as string; + const missingObjVal = ['', { idx: -1 }] as const; + const [firstObjKey, { idx: firstObjIdx }] = selectedObjs[0] ?? missingObjVal; + const [lastObjKey, { idx: lastObjIdx }] = + selectedObjs[selectedObjs.length - 1] ?? missingObjVal; - const getRowPath = (row: Row) => - typeof row.original === 'string' ? row.original : row.original.key; - const getIndex = (key: string) => rows.findIndex((row) => key === getRowPath(row)); + if (firstObjIdx === -1 || lastObjIdx === -1) { + // do nothing + } else if (opts.down ? firstObjIdx > lastObjIdx : firstObjIdx < lastObjIdx) { + removeSelectedObject(opts.down ? firstObjKey : lastObjKey); + } else { + const nextIdx = opts.down ? lastObjIdx + 1 : lastObjIdx - 1; + const nextObj = rows[nextIdx]; + if (nextObj) addSelectedObject(nextObj.original.path, { idx: nextIdx }, !opts.shiftKey); + } + }, + [selectedObjects, removeSelectedObject, rows, addSelectedObject], + ); + + const handleKeyPress = useCallback( + (e: KeyboardEvent) => { + if (isPreviewActive) return; switch (e.key) { case 'Enter': { // trigger preview for selected objects. if (selectedObjects.size === 1) { const nextObj = selectedObjects.values().next().value as string; - handleDoubleClick({ - isDirectory: nextObj.endsWith('/'), - path: nextObj, - }); + handleDoubleClick({ isDirectory: nextObj.endsWith('/'), path: nextObj }); } else if (selectedObjects.size > 1) { // eslint-disable-next-line no-console console.warn('Multiple objects selected. Preview not supported.'); } - e.stopPropagation(); - e.preventDefault(); break; } case 'ArrowDown': { if (selectedObjects.size === 0) { const first = rows[0]; - if (first) addSelectedObject(getRowPath(first), !e.shiftKey); + if (first) addSelectedObject(first.original.path, { idx: 0 }, !e.shiftKey); } else { - const firstSelected = getFirstSelected(); - const firstSelectedIdx = getIndex(firstSelected); - const lastSelected = getLastSelected(); - const lastSelectedIdx = getIndex(lastSelected); - - if (firstSelectedIdx === -1 || lastSelectedIdx === -1) { - // do nothing - } else if (firstSelectedIdx > lastSelectedIdx) { - removeSelectedObject(lastSelected); - } else { - const nextObj = rows[lastSelectedIdx + 1]; - if (nextObj) addSelectedObject(getRowPath(nextObj), !e.shiftKey); - } + selectNextObject({ down: true, shiftKey: e.shiftKey }); } - e.stopPropagation(); - e.preventDefault(); break; } case 'ArrowUp': { if (selectedObjects.size === 0 && rows[0]) { const last = rows[rows.length - 1]; - if (last) addSelectedObject(getRowPath(last), !e.shiftKey); + if (last) addSelectedObject(last.original.path, { idx: rows.length - 1 }, !e.shiftKey); } else { - const firstSelected = getFirstSelected(); - const firstSelectedIdx = getIndex(firstSelected); - const lastSelected = getLastSelected(); - const lastSelectedIdx = getIndex(lastSelected); - - if (firstSelectedIdx === -1 || lastSelectedIdx === -1) { - // do nothing - } else if (firstSelectedIdx < lastSelectedIdx) { - removeSelectedObject(lastSelected); - } else { - const nextObj = rows[lastSelectedIdx - 1]; - if (nextObj) addSelectedObject(getRowPath(nextObj), !e.shiftKey); - } + selectNextObject({ up: true, shiftKey: e.shiftKey }); } - e.stopPropagation(); - e.preventDefault(); break; } case 'Escape': { @@ -242,20 +237,53 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El break; } default: - break; + return; } + + e.stopPropagation(); + e.preventDefault(); }, [ addSelectedObject, clearSelectedObjects, handleDoubleClick, - isFilePreviewActive, - removeSelectedObject, + isPreviewActive, rows, + selectNextObject, selectedObjects, ], ); + const handleMouseDown = useCallback( + (e: React.MouseEvent, object: FileObject, objInfo: { idx: number }) => { + // We only trigger selection on left click. + // `onMouseDown` is used instead of `onClick` so we can mark an object as selected on focusing instead of after the click completes. + if (e.button !== 0) return; + // TODO: Trigger context menu through this handler instead. + + if (e.ctrlKey || e.metaKey) { + addSelectedObject(object.path, objInfo); + } else if (e.shiftKey) { + const [, { idx: lastSelectedIdx }] = [...selectedObjects.entries()].reverse()[0] as [ + string, + { idx: number }, + ]; + + if (lastSelectedIdx === -1) { + addSelectedObject(object.path, objInfo); + } else { + for (let i = lastSelectedIdx; i <= objInfo.idx; i++) { + const nextObj = rows[i]; + if (nextObj) addSelectedObject(nextObj.original.path, { idx: i }); + } + } + } else { + addSelectedObject(object.path, objInfo, true); + } + }, + [addSelectedObject, rows, selectedObjects], + ); + useEffect(() => { document.addEventListener('keydown', handleKeyPress); @@ -263,7 +291,7 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El }, [handleKeyPress]); return ( -
+
{table.getHeaderGroups().map((headerGroup) => (
@@ -298,7 +326,14 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El const row = rows[virtualRow.index]; if (!row) return null; - return ; + return ( + handleMouseDown(e, obj, { idx: virtualRow.index })} + /> + ); })} {paddingBottom > 0 &&
}
diff --git a/components/object-explorer/index.ts b/components/object-explorer/index.ts index 6debd4c..c535933 100644 --- a/components/object-explorer/index.ts +++ b/components/object-explorer/index.ts @@ -1 +1,3 @@ export { ObjectExplorer } from './explorer'; +export { ObjectPreview } from './object-preview'; +export { PreviewPane, TogglePreviewPaneButton } from './preview-pane'; diff --git a/components/object-explorer/object-preview-inner.tsx b/components/object-explorer/object-preview-inner.tsx new file mode 100644 index 0000000..11e400e --- /dev/null +++ b/components/object-explorer/object-preview-inner.tsx @@ -0,0 +1,63 @@ +import { addLeadingSlash } from '@/utils'; +import type { FileType } from '@/utils'; +import { memo } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { useLocation } from '../providers'; +import { getFileIcon } from './file-icons'; + +type Props = { + className?: string; + path: string; + type: FileType; +}; + +const FallbackIcon = ({ type: itemType }: Pick) => { + const Icon = getFileIcon(itemType); + return ; +}; + +export const ObjectPreviewInner = memo( + ({ className, path, type: itemType }: Props) => { + const { currentBucket } = useLocation(); + if (!currentBucket || !path || !itemType) return null; + + const itemApiSrc = `/api/bucket/${currentBucket?.raw}${addLeadingSlash(path)}`; + + switch (itemType) { + case 'image': { + return ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {path} + {/* eslint-disable-next-line @next/next/no-img-element */} + {path} + + + ); + } + case 'video': { + return ( + <> + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +