import { api } from "@fileone/convex/_generated/api" import type { Doc } from "@fileone/convex/_generated/dataModel" import type { DirectoryItem } from "@fileone/convex/model/directories" import { newDirectoryHandle, newFileHandle, } from "@fileone/convex/model/filesystem" import { useMutation } from "@tanstack/react-query" import { Link, useNavigate } from "@tanstack/react-router" import { type ColumnDef, flexRender, getCoreRowModel, type Row, useReactTable, } from "@tanstack/react-table" import { useMutation as useContextMutation } from "convex/react" import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { CheckIcon, TextCursorInputIcon, TrashIcon, XIcon } from "lucide-react" import { useContext, useEffect, useId, useRef } from "react" import { toast } from "sonner" import { DirectoryIcon } from "@/components/icons/directory-icon" import { Checkbox } from "@/components/ui/checkbox" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { isControlOrCommandKeyActive, keyboardModifierAtom, } from "@/lib/keyboard" import { TextFileIcon } from "../../components/icons/text-file-icon" import { Button } from "../../components/ui/button" import { LoadingSpinner } from "../../components/ui/loading-spinner" import { useFileDrop } from "../../files/use-file-drop" import { withDefaultOnError } from "../../lib/error" import { cn } from "../../lib/utils" import { DirectoryPageContext } from "./context" import { contextMenuTargeItemAtom, dragInfoAtom, itemBeingRenamedAtom, newItemKindAtom, 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]}` } const columns: ColumnDef[] = [ { id: "select", header: ({ table }) => ( { table.toggleAllPageRowsSelected(!!value) }} aria-label="Select all" /> ), cell: ({ row }) => ( { 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) { case "file": return case "directory": return } }, size: 1000, }, { header: "Size", accessorKey: "size", cell: ({ row }) => { switch (row.original.kind) { case "file": return
{formatFileSize(row.original.doc.size)}
case "directory": return
-
} }, }, { header: "Created At", accessorKey: "createdAt", cell: ({ row }) => { return (
{new Date(row.original.doc.createdAt).toLocaleString()}
) }, }, ] export function DirectoryContentTable() { return (
) } export function DirectoryContentTableContextMenu({ children, }: { children: React.ReactNode }) { const store = useStore() const target = useAtomValue(contextMenuTargeItemAtom) const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom) const moveToTrashMutation = useContextMutation(api.files.moveToTrash) const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom) const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom) const { mutate: moveToTrash } = useMutation({ mutationFn: moveToTrashMutation, onMutate: ({ itemId }) => { setOptimisticDeletedItems((prev) => new Set([...prev, itemId])) }, onSuccess: (itemId) => { setOptimisticDeletedItems((prev) => { const newSet = new Set(prev) newSet.delete(itemId) return newSet }) toast.success("Moved to trash") }, }) const handleRename = () => { const selectedItem = store.get(contextMenuTargeItemAtom) if (selectedItem) { setItemBeingRenamed({ kind: selectedItem.kind, originalItem: selectedItem, name: selectedItem.doc.name, }) } } const handleDelete = () => { const selectedItem = store.get(contextMenuTargeItemAtom) if (selectedItem) { moveToTrash({ kind: selectedItem.kind, itemId: selectedItem.doc._id, }) } } return ( { if (!open) { setContextMenuTargetItem(null) } }} > {children} {target && ( Rename Move to trash )} ) } export function DirectoryContentTableContent() { const { directoryContent } = useContext(DirectoryPageContext) const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom) const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom) const store = useStore() const navigate = useNavigate() const handleRowContextMenu = ( row: Row, _event: React.MouseEvent, ) => { const target = store.get(contextMenuTargeItemAtom) if (target === row.original) { setContextMenuTargetItem(null) } else { selectRow(row) setContextMenuTargetItem(row.original) } } 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, }) const selectRow = (row: Row) => { const keyboardModifiers = store.get(keyboardModifierAtom) const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers) const isRowSelected = row.getIsSelected() console.log({ isMultiSelectMode, isRowSelected }) if (isRowSelected && isMultiSelectMode) { row.toggleSelected(false) } else if (isRowSelected && !isMultiSelectMode) { table.setRowSelection({ [row.id]: true, }) row.toggleSelected(true) } else if (!isRowSelected) { if (isMultiSelectMode) { row.toggleSelected(true) } else { table.setRowSelection({ [row.id]: true, }) } } } const handleRowDoubleClick = (row: Row) => { if (row.original.kind === "directory") { navigate({ to: `/directories/${row.original.doc._id}`, }) } } return (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext(), )} ))} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( selectRow(row)} onContextMenu={(e) => handleRowContextMenu(row, e) } onDoubleClick={() => { handleRowDoubleClick(row) }} /> )) ) : ( )}
) } function NoResultsRow() { const newItemKind = useAtomValue(newItemKindAtom) if (newItemKind) { return null } return ( No results. ) } function NewItemRow() { const { directory } = useContext(DirectoryPageContext) const inputRef = useRef(null) const newItemFormId = useId() const [newItemKind, setNewItemKind] = useAtom(newItemKindAtom) const { mutate: createDirectory, isPending } = useMutation({ mutationFn: useContextMutation(api.files.createDirectory), onSuccess: () => { setNewItemKind(null) }, onError: withDefaultOnError(() => { setTimeout(() => { inputRef.current?.focus() }, 1) }), }) useEffect(() => { if (newItemKind) { setTimeout(() => { inputRef.current?.focus() }, 1) } }, [newItemKind]) if (!newItemKind) { return null } const onSubmit = (event: React.FormEvent) => { event.preventDefault() const formData = new FormData(event.currentTarget) const itemName = formData.get("itemName") as string if (itemName) { createDirectory({ name: itemName, directoryId: directory._id }) } else { toast.error("Please enter a name.") } } const clearNewItemKind = () => { // setItemBeingAdded(null) setNewItemKind(null) } return (
{isPending ? ( ) : ( )}
{!isPending ? ( <> ) : null}
) } function FileItemRow({ row, onClick, onContextMenu, onDoubleClick, }: { row: Row onClick: () => void onContextMenu: (e: React.MouseEvent) => void onDoubleClick: () => void }) { const ref = useRef(null) const setDragInfo = useSetAtom(dragInfoAtom) const { isDraggedOver, dropHandlers } = useFileDrop({ item: row.original.kind === "directory" ? newDirectoryHandle(row.original.doc._id) : null, dragInfoAtom, }) const handleDragStart = (e: React.DragEvent) => { if (row.original.kind === "file") { e.dataTransfer.setData( "application/x-internal", JSON.stringify(row.original), ) const fileHandle = newFileHandle(row.original.doc._id) setDragInfo({ source: fileHandle, items: [fileHandle], }) } } const handleDragEnd = () => { setDragInfo(null) } return ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) } function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) { return (
{directory.name}
) } function FileNameCell({ file }: { file: Doc<"files"> }) { const setOpenedFile = useSetAtom(openedFileAtom) return (
) }