Files
drive/packages/web/src/directories/directory-page/directory-content-table.tsx

388 lines
9.0 KiB
TypeScript
Raw Normal View History

import type { Doc } from "@fileone/convex/_generated/dataModel"
import {
2025-09-28 15:45:49 +00:00
type DirectoryHandle,
type FileHandle,
type FileSystemHandle,
2025-09-28 15:45:49 +00:00
type FileSystemItem,
FileType,
2025-09-26 22:20:30 +00:00
isSameHandle,
newDirectoryHandle,
newFileHandle,
2025-09-28 15:45:49 +00:00
newFileSystemHandle,
} from "@fileone/convex/model/filesystem"
import { Link, useNavigate } from "@tanstack/react-router"
import {
type ColumnDef,
flexRender,
getCoreRowModel,
type Row,
type Table as TableType,
useReactTable,
} from "@tanstack/react-table"
import { useAtomValue, useSetAtom, useStore } from "jotai"
2025-09-28 15:45:49 +00:00
import { useContext, useEffect, useRef } from "react"
import { DirectoryIcon } from "@/components/icons/directory-icon"
import { Checkbox } from "@/components/ui/checkbox"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
2025-09-21 15:12:05 +00:00
import {
isControlOrCommandKeyActive,
keyboardModifierAtom,
} from "@/lib/keyboard"
import { TextFileIcon } from "../../components/icons/text-file-icon"
import { useFileDrop } from "../../files/use-file-drop"
import { cn } from "../../lib/utils"
import { DirectoryPageContext } from "./context"
import { DirectoryContentContextMenu } from "./directory-content-context-menu"
import {
2025-09-28 15:45:49 +00:00
contextMenuTargeItemsAtom,
2025-09-20 22:25:01 +00:00
dragInfoAtom,
2025-09-20 19:55:20 +00:00
openedFileAtom,
optimisticDeletedItemsAtom,
} from "./state"
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
2025-09-28 15:45:49 +00:00
const columns: ColumnDef<FileSystemItem>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
2025-09-21 15:12:05 +00:00
onCheckedChange={(value) => {
table.toggleAllPageRowsSelected(!!value)
2025-09-21 15:12:05 +00:00
}}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
2025-09-21 15:12:05 +00:00
onClick={(e) => {
e.stopPropagation()
}}
onCheckedChange={row.getToggleSelectedHandler()}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 24,
},
{
header: "Name",
accessorKey: "doc.name",
cell: ({ row }) => {
switch (row.original.kind) {
2025-09-28 15:45:49 +00:00
case FileType.File:
2025-09-20 19:55:20 +00:00
return <FileNameCell file={row.original.doc} />
2025-09-28 15:45:49 +00:00
case FileType.Directory:
return <DirectoryNameCell directory={row.original.doc} />
}
},
size: 1000,
},
{
header: "Size",
accessorKey: "size",
cell: ({ row }) => {
switch (row.original.kind) {
2025-09-28 15:45:49 +00:00
case FileType.File:
return <div>{formatFileSize(row.original.doc.size)}</div>
2025-09-28 15:45:49 +00:00
case FileType.Directory:
return <div className="font-mono">-</div>
}
},
},
{
header: "Created At",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<div>
{new Date(row.original.doc.createdAt).toLocaleString()}
</div>
)
},
},
]
export function DirectoryContentTable() {
return (
<DirectoryContentContextMenu>
<div className="w-full">
<DirectoryContentTableContent />
</div>
</DirectoryContentContextMenu>
)
}
export function DirectoryContentTableContent() {
const { directoryContent } = useContext(DirectoryPageContext)
const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom)
2025-09-28 15:45:49 +00:00
const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemsAtom)
const store = useStore()
const navigate = useNavigate()
2025-09-26 22:28:51 +00:00
const table = useReactTable({
data: directoryContent || [],
columns,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
enableGlobalFilter: true,
globalFilterFn: (row, _columnId, _filterValue, _addMeta) => {
return !optimisticDeletedItems.has(row.original.doc._id)
},
getRowId: (row) => row.doc._id,
})
useEffect(
function escapeToClearSelections() {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
table.setRowSelection({})
}
}
window.addEventListener("keydown", handleEscape)
return () => window.removeEventListener("keydown", handleEscape)
},
[table.setRowSelection],
)
const handleRowContextMenu = (
2025-09-28 15:45:49 +00:00
row: Row<FileSystemItem>,
_event: React.MouseEvent,
) => {
2025-09-28 15:45:49 +00:00
const target = store.get(contextMenuTargeItemsAtom)
if (target.length > 0) {
setContextMenuTargetItem([])
} else if (row.getIsSelected()) {
setContextMenuTargetItem(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
selectRow(row)
2025-09-28 15:45:49 +00:00
setContextMenuTargetItem([row.original])
}
}
2025-09-28 15:45:49 +00:00
const selectRow = (row: Row<FileSystemItem>) => {
2025-09-21 15:12:05 +00:00
const keyboardModifiers = store.get(keyboardModifierAtom)
const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers)
const isRowSelected = row.getIsSelected()
if (isRowSelected && isMultiSelectMode) {
row.toggleSelected(false)
} else if (isRowSelected && !isMultiSelectMode) {
table.setRowSelection({
[row.id]: true,
})
row.toggleSelected(true)
2025-09-21 15:12:05 +00:00
} else if (!isRowSelected) {
if (isMultiSelectMode) {
row.toggleSelected(true)
} else {
table.setRowSelection({
[row.id]: true,
})
}
}
}
2025-09-28 15:45:49 +00:00
const handleRowDoubleClick = (row: Row<FileSystemItem>) => {
if (row.original.kind === FileType.Directory) {
navigate({
to: `/directories/${row.original.doc._id}`,
})
}
}
return (
<div className="overflow-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow className="px-4" key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
className="first:pl-4 last:pr-4"
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<FileItemRow
key={row.id}
table={table}
row={row}
onClick={() => selectRow(row)}
onContextMenu={(e) =>
handleRowContextMenu(row, e)
}
onDoubleClick={() => {
handleRowDoubleClick(row)
}}
/>
))
) : (
<NoResultsRow />
)}
</TableBody>
</Table>
</div>
)
}
function NoResultsRow() {
return (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
No results.
</TableCell>
</TableRow>
)
}
2025-09-20 22:25:01 +00:00
function FileItemRow({
table,
2025-09-20 22:25:01 +00:00
row,
onClick,
onContextMenu,
onDoubleClick,
2025-09-20 22:25:01 +00:00
}: {
2025-09-28 15:45:49 +00:00
table: TableType<FileSystemItem>
row: Row<FileSystemItem>
2025-09-20 22:25:01 +00:00
onClick: () => void
onContextMenu: (e: React.MouseEvent) => void
onDoubleClick: () => void
2025-09-20 22:25:01 +00:00
}) {
const ref = useRef<HTMLTableRowElement>(null)
const setDragInfo = useSetAtom(dragInfoAtom)
const { isDraggedOver, dropHandlers } = useFileDrop({
2025-09-26 22:20:30 +00:00
destItem:
2025-09-28 15:45:49 +00:00
row.original.kind === FileType.Directory
? newDirectoryHandle(row.original.doc._id)
: null,
dragInfoAtom,
2025-09-20 22:25:01 +00:00
})
2025-09-28 15:45:49 +00:00
const handleDragStart = (_e: React.DragEvent) => {
let source: DirectoryHandle | FileHandle
switch (row.original.kind) {
2025-09-28 15:45:49 +00:00
case FileType.File:
source = newFileHandle(row.original.doc._id)
break
2025-09-28 15:45:49 +00:00
case FileType.Directory:
source = newDirectoryHandle(row.original.doc._id)
break
2025-09-20 22:25:01 +00:00
}
2025-09-26 22:20:30 +00:00
let draggedItems: FileSystemHandle[]
// drag all selections, but only if the currently dragged row is also selected
if (row.getIsSelected()) {
2025-09-28 15:45:49 +00:00
draggedItems = table
.getSelectedRowModel()
.rows.map((row) => newFileSystemHandle(row.original))
2025-09-26 22:20:30 +00:00
if (!draggedItems.some((item) => isSameHandle(item, source))) {
draggedItems.push(source)
}
2025-09-26 22:20:30 +00:00
} else {
draggedItems = [source]
}
setDragInfo({
source,
items: draggedItems,
})
2025-09-20 22:25:01 +00:00
}
const handleDragEnd = () => {
2025-09-20 22:25:01 +00:00
setDragInfo(null)
}
return (
<TableRow
draggable
ref={ref}
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={onClick}
onDoubleClick={onDoubleClick}
2025-09-20 22:25:01 +00:00
onContextMenu={onContextMenu}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
{...dropHandlers}
2025-09-20 22:25:01 +00:00
className={cn({ "bg-muted": isDraggedOver })}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className="first:pl-4 last:pr-4"
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) {
return (
<div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" />
<Link
className="hover:underline"
to={`/directories/${directory._id}`}
>
{directory.name}
</Link>
</div>
)
}
2025-09-20 19:55:20 +00:00
function FileNameCell({ file }: { file: Doc<"files"> }) {
const setOpenedFile = useSetAtom(openedFileAtom)
2025-09-20 22:25:01 +00:00
return (
<div className="flex w-full items-center gap-2">
<TextFileIcon className="size-4" />
2025-09-20 19:55:20 +00:00
<button
type="button"
className="hover:underline cursor-pointer"
onClick={() => {
setOpenedFile(file)
}}
>
{file.name}
</button>
</div>
)
}