Skip to content

Commit

Permalink
feat: basic grid view support (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
james-elicx authored Oct 8, 2023
1 parent 588bfe3 commit 0fa0f26
Show file tree
Hide file tree
Showing 15 changed files with 293 additions and 80 deletions.
3 changes: 2 additions & 1 deletion app/api/bucket/[bucket]/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export const GET = async (
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);

return new Response(object.body, { headers });
// TODO: Something broke with object.body here.
return new Response(await object.arrayBuffer(), { headers });
};

export const POST = async (
Expand Down
20 changes: 9 additions & 11 deletions app/bucket/[bucket]/[[...path]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,15 @@ const Page = async ({ params: { bucket, path } }: Props) => {
const objects = [...items.delimitedPrefixes, ...items.objects];

return (
<main className="flex h-full w-full flex-row justify-between gap-4 px-4">
<div className="flex flex-grow flex-col justify-between overflow-x-auto">
{items.delimitedPrefixes.length === 0 && items.objects.length === 0 ? (
<span className="flex flex-grow items-center justify-center">No items found...</span>
) : (
<ObjectExplorer
initialObjects={objects}
initialCursor={items.truncated ? items.cursor : undefined}
/>
)}
</div>
<main className="flex h-full max-h-[calc(100%-4rem)] w-full max-w-[calc(100vw-16rem)] flex-row justify-between gap-4 px-4">
{items.delimitedPrefixes.length === 0 && items.objects.length === 0 ? (
<span className="flex flex-grow items-center justify-center">No items found...</span>
) : (
<ObjectExplorer
initialObjects={objects}
initialCursor={items.truncated ? items.cursor : undefined}
/>
)}

<PreviewPane />
</main>
Expand Down
2 changes: 1 addition & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const Layout = async ({ children }: Props) => {
<div className="flex flex-grow flex-row bg-background dark:bg-background-dark">
<SideNav />

<div className="flex h-screen flex-grow flex-col overflow-y-auto">
<div className="flex h-screen flex-grow flex-col">
<TopNav />

{children}
Expand Down
2 changes: 2 additions & 0 deletions components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ export {
CaretCircleDown,
Door,
DoorOpen,
List,
GridFour,
} from '@phosphor-icons/react';
4 changes: 3 additions & 1 deletion components/navs/top-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +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';
import { ToggleGridViewButton, TogglePreviewPaneButton } from '../object-explorer';

const TopNavSection = ({ children }: { children: React.ReactNode }): JSX.Element => (
<div className="flex flex-row items-center gap-4">{children}</div>
Expand Down Expand Up @@ -80,6 +80,8 @@ export const TopNav = (): JSX.Element => {
</TopNavSection>

<TopNavSection>
<ToggleGridViewButton />

<TogglePreviewPaneButton />

<UploadFilesProvider>
Expand Down
179 changes: 129 additions & 50 deletions components/object-explorer/explorer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
'use client';

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { twMerge } from 'tailwind-merge';
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import {
Expand All @@ -11,20 +19,24 @@ import {
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { parseObject, type FileObject } from '@/utils';
import { useOnClickOutside } from '@/utils/hooks';
import { useOnClickOutside, useResizeObserver } from '@/utils/hooks';
import { alphanumeric } from '@/utils/compare-alphanumeric-patched';
import { getFileIcon, getSortIcon } from './file-icons';
import { ObjectRow } from './object-row';
import { useExplorerEvents, useObjectExplorer } from '../providers';
import { useExplorerEvents, useObjectExplorer, useSettings } from '../providers';
import { ObjectGridItem } from './object-grid-item';

// TODO: Settings context that persists size and order of columns

type Props = {
initialObjects: (string | R2Object)[];
initialCursor?: string;
};

// TODO: Settings context that persists size and order of columns

export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.Element => {
const { isGridView } = useSettings();
const grid = { width: 140, height: 170, padding: 16 };

const parentRef = useRef<HTMLDivElement>(null);

const parsedInitialObjects = useMemo(
Expand Down Expand Up @@ -160,19 +172,42 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El

const { rows } = table.getRowModel();

const [gridColumns, setGridColumns] = useState(
parentRef.current ? Math.floor(parentRef.current.clientWidth / grid.width) : 5,
);
useResizeObserver(parentRef, ({ width }) => {
setGridColumns(Math.floor(width / grid.width));
});

const rowVirtualizer = useVirtualizer({
count: rows.length, // Should this track some sort of count from R2?
estimateSize: () => 40,
estimateSize: () => (isGridView ? grid.height : 40),
getScrollElement: useCallback(() => parentRef.current, []),
overscan: 25,
});

const columnVirtualizer = useVirtualizer({
horizontal: true,
count: gridColumns,
getScrollElement: useCallback(() => parentRef.current, []),
estimateSize: () => (isGridView ? grid.width : 40),
});

useEffect(() => {
rowVirtualizer.measure();
columnVirtualizer.measure();
}, [columnVirtualizer, isGridView, rowVirtualizer]);

const virtualRows = rowVirtualizer.getVirtualItems();
const virtualColumns = columnVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();

const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
virtualRows.length > 0
? (totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0)) /
(isGridView ? gridColumns : 1)
: 0;

const { handleDoubleClick } = useExplorerEvents();

Expand Down Expand Up @@ -206,7 +241,7 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El
case 'Enter': {
// trigger preview for selected objects.
if (selectedObjects.size === 1) {
const nextObj = selectedObjects.values().next().value as string;
const nextObj = selectedObjects.keys().next().value as string;
handleDoubleClick({ isDirectory: nextObj.endsWith('/'), path: nextObj });
} else if (selectedObjects.size > 1) {
// eslint-disable-next-line no-console
Expand Down Expand Up @@ -291,51 +326,95 @@ export const ObjectExplorer = ({ initialObjects, initialCursor }: Props): JSX.El
}, [handleKeyPress]);

return (
<div className="table">
<div className="table-header-group">
{table.getHeaderGroups().map((headerGroup) => (
<div key={headerGroup.id} className="flex flex-grow flex-row py-2">
{headerGroup.headers.map((header) => (
<button
key={header.id}
type="button"
className={twMerge(
'flex flex-row items-center justify-between text-left text-sm font-normal',
header.index === 0 && 'pl-20', // padding due to the icon being in the file name column
header.column.getIsSorted() && 'font-medium',
header.column.getCanSort() && 'cursor-pointer select-none',
)}
style={{ width: header.column.getSize() }}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{getSortIcon(header.column.getIsSorted())}
</button>
<div
className="flex flex-grow flex-col justify-between overflow-x-auto overflow-y-auto"
onScroll={isGridView ? () => {} : handleScroll}
>
<div className="table">
{!isGridView && (
<div className="table-header-group">
{table.getHeaderGroups().map((headerGroup) => (
<div key={headerGroup.id} className="flex flex-grow flex-row py-2">
{headerGroup.headers.map((header) => (
<button
key={header.id}
type="button"
className={twMerge(
'flex flex-row items-center justify-between text-left text-sm font-normal',
header.index === 0 && 'pl-20', // padding due to the icon being in the file name column
header.column.getIsSorted() && 'font-medium',
header.column.getCanSort() && 'cursor-pointer select-none',
)}
style={{ width: header.column.getSize() }}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{getSortIcon(header.column.getIsSorted())}
</button>
))}
</div>
))}
</div>
))}
</div>
)}

<div
className="table-row-group max-h-[calc(100vh-100px)] overflow-y-auto [&>:nth-child(odd)]:bg-secondary/40 dark:[&>:nth-child(odd)]:bg-secondary-dark/40"
onScroll={handleScroll}
ref={parentRef}
>
{paddingTop > 0 && <div style={{ height: `${paddingTop}px` }} />}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;

return (
<ObjectRow
key={row.id}
row={row}
virtualRowSize={virtualRow.size}
handleClick={(e, obj) => handleMouseDown(e, obj, { idx: virtualRow.index })}
/>
);
})}
{paddingBottom > 0 && <div style={{ height: `${paddingBottom}px` }} />}
<div
className={
isGridView
? 'relative -ml-4 max-h-[calc(100vh-100px)] w-full overflow-y-auto'
: 'table-row-group [&>:nth-child(odd)]:bg-secondary/40 dark:[&>:nth-child(odd)]:bg-secondary-dark/40'
}
ref={parentRef}
style={isGridView ? { height: `${rowVirtualizer.getTotalSize()}px` } : {}}
onScroll={isGridView ? handleScroll : () => {}}
>
{paddingTop > 0 && <div style={{ height: `${paddingTop}px` }} />}
{isGridView
? virtualRows.map((virtualRow) => (
<Fragment key={virtualRow.index}>
{virtualColumns.map((virtualColumn) => {
const index = virtualColumn.index + virtualRow.index * gridColumns;
if (index >= rows.length) return null;

const row = rows[index];
if (!row) return null;

return (
<ObjectGridItem
key={row.id}
row={row}
handleClick={(e, obj) => handleMouseDown(e, obj, { idx: index })}
style={{
width: grid.width,
height: grid.height,
paddingLeft: grid.padding,
paddingTop: grid.padding,
transform: `translateX(${virtualColumn.start}px) translateY(${
virtualRow.start - rowVirtualizer.options.scrollMargin
}px)`,
}}
previewSize={grid.width - grid.padding}
/>
);
})}
</Fragment>
))
: virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;

return (
<ObjectRow
key={row.id}
row={row}
virtualRowSize={virtualRow.size}
handleClick={(e, obj) => handleMouseDown(e, obj, { idx: virtualRow.index })}
/>
);
})}
{paddingBottom > 0 && (
<div style={{ height: `${paddingBottom / (isGridView ? gridColumns : 1)}px` }} />
)}
</div>
</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions components/object-explorer/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { ObjectExplorer } from './explorer';
export { ObjectPreview } from './object-preview';
export { PreviewPane, TogglePreviewPaneButton } from './preview-pane';
export { ToggleGridViewButton } from './toggle-grid-view';
53 changes: 53 additions & 0 deletions components/object-explorer/object-grid-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use client';

import { twMerge } from 'tailwind-merge';
import type { FileObject } from '@/utils';
import type { Row } from '@tanstack/react-table';
import { useObjectExplorer, useExplorerEvents } from '../providers';
import { ObjectPreviewInner } from './object-preview-inner';

type Props = {
row: Row<FileObject>;
handleClick: (e: React.MouseEvent<HTMLElement>, object: FileObject) => void;
style: React.CSSProperties;
previewSize: number;
};

export const ObjectGridItem = ({ row, handleClick, style, previewSize }: Props): JSX.Element => {
const { selectedObjects } = useObjectExplorer();
const { handleDoubleClick } = useExplorerEvents();

const object = row.original;
const isSelected = selectedObjects.has(object.path);

return (
<button
className="flex flex-col justify-between truncate"
type="button"
onMouseDown={(e) => handleClick(e, object)}
onDoubleClick={() => handleDoubleClick(object)}
style={{ position: 'absolute', top: 0, left: 0, ...style }}
>
<div
className="relative flex w-full items-center justify-center overflow-hidden rounded-md bg-secondary/30 dark:bg-secondary-dark/30"
style={{ width: `${previewSize}px`, height: `${previewSize}px` }}
>
<ObjectPreviewInner path={object.path} type={object.getType()} />
</div>
<span
className={twMerge(
'mx-auto max-w-full truncate text-sm font-semibold',
'select-none rounded-md px-1 py-[0.0725rem]',
'border-1 border-transparent',
isSelected &&
'border-accent !bg-secondary dark:border-accent-dark dark:!bg-secondary-dark',
)}
title={object.getName()}
>
{object.getName()}
</span>
</button>
);
};

export type { Props as ObjectGridItemProps };
8 changes: 3 additions & 5 deletions components/object-explorer/preview-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,16 @@ export const PreviewPane = () => {
export const TogglePreviewPaneButton = () => {
const { isPreviewPaneActive, togglePreviewPane } = useSettings();

const Icon = isPreviewPaneActive ? Door : DoorOpen;

return (
<button
type="button"
title={`${isPreviewPaneActive ? 'Hide' : 'Show'} Preview Pane`}
className="text-primary transition-all hover:text-accent dark:text-primary-dark dark:hover:text-accent-dark"
onClick={() => togglePreviewPane()}
>
{isPreviewPaneActive ? (
<Door weight="bold" className="h-5 w-5" />
) : (
<DoorOpen weight="bold" className="h-5 w-5" />
)}
<Icon weight="bold" className="h-5 w-5" />
</button>
);
};
Loading

0 comments on commit 0fa0f26

Please sign in to comment.