diff --git a/packages/web/src/directories/directory-page/directory-content-table.tsx b/packages/web/src/directories/directory-page/directory-content-table.tsx index 968e018..f60de21 100644 --- a/packages/web/src/directories/directory-page/directory-content-table.tsx +++ b/packages/web/src/directories/directory-page/directory-content-table.tsx @@ -19,8 +19,8 @@ import { type Table as TableType, useReactTable, } from "@tanstack/react-table" -import { useAtomValue, useSetAtom, useStore } from "jotai" -import { useContext, useEffect, useRef } from "react" +import { type PrimitiveAtom, useSetAtom, useStore } from "jotai" +import { useContext, useEffect, useMemo, useRef } from "react" import { DirectoryIcon } from "@/components/icons/directory-icon" import { Checkbox } from "@/components/ui/checkbox" import { @@ -36,16 +36,17 @@ import { keyboardModifierAtom, } from "@/lib/keyboard" import { TextFileIcon } from "../../components/icons/text-file-icon" -import { useFileDrop } from "../../files/use-file-drop" +import { type FileDragInfo, useFileDrop } from "../../files/use-file-drop" import { cn } from "../../lib/utils" import { DirectoryPageContext } from "./context" -import { DirectoryContentContextMenu } from "./directory-content-context-menu" -import { - contextMenuTargeItemsAtom, - dragInfoAtom, - openedFileAtom, - optimisticDeletedItemsAtom, -} from "./state" +import { contextMenuTargeItemsAtom } from "./state" + +type DirectoryContentTableProps = { + filterFn: (item: FileSystemItem) => boolean + fileDragInfoAtom: PrimitiveAtom + onContextMenu: (items: FileSystemItem[]) => void + onOpenFile: (file: Doc<"files">) => void +} function formatFileSize(bytes: number): string { if (bytes === 0) return "0 B" @@ -57,96 +58,118 @@ function formatFileSize(bytes: number): string { 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 FileType.File: - return - case FileType.Directory: - return - } - }, - size: 1000, - }, - { - header: "Size", - accessorKey: "size", - cell: ({ row }) => { - switch (row.original.kind) { - case FileType.File: - return
{formatFileSize(row.original.doc.size)}
- case FileType.Directory: - return
-
- } - }, - }, - { - header: "Created At", - accessorKey: "createdAt", - cell: ({ row }) => { - return ( -
- {new Date(row.original.doc.createdAt).toLocaleString()} -
- ) - }, - }, -] - -export function DirectoryContentTable() { - return ( - -
- -
-
+function useTableColumns( + onOpenFile: (file: Doc<"files">) => void, +): ColumnDef[] { + return useMemo( + () => [ + { + 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 FileType.File: + return ( + + ) + case FileType.Directory: + return ( + + ) + } + }, + size: 1000, + }, + { + header: "Size", + accessorKey: "size", + cell: ({ row }) => { + switch (row.original.kind) { + case FileType.File: + return ( +
+ {formatFileSize(row.original.doc.size)} +
+ ) + case FileType.Directory: + return
-
+ } + }, + }, + { + header: "Created At", + accessorKey: "createdAt", + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.doc.createdAt, + ).toLocaleString()} +
+ ) + }, + }, + ], + [onOpenFile], ) } -export function DirectoryContentTableContent() { +export function DirectoryContentTable(props: DirectoryContentTableProps) { + return ( +
+ +
+ ) +} + +export function DirectoryContentTableContent({ + filterFn, + onContextMenu, + fileDragInfoAtom, + onOpenFile, +}: DirectoryContentTableProps) { const { directoryContent } = useContext(DirectoryPageContext) - const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom) - const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemsAtom) const store = useStore() const navigate = useNavigate() const table = useReactTable({ data: directoryContent || [], - columns, + columns: useTableColumns(onOpenFile), getCoreRowModel: getCoreRowModel(), enableRowSelection: true, enableGlobalFilter: true, - globalFilterFn: (row, _columnId, _filterValue, _addMeta) => { - return !optimisticDeletedItems.has(row.original.doc._id) - }, + globalFilterFn: (row, _columnId, _filterValue, _addMeta) => + filterFn(row.original), getRowId: (row) => row.doc._id, }) @@ -169,14 +192,14 @@ export function DirectoryContentTableContent() { ) => { const target = store.get(contextMenuTargeItemsAtom) if (target.length > 0) { - setContextMenuTargetItem([]) + onContextMenu(target) } else if (row.getIsSelected()) { - setContextMenuTargetItem( + onContextMenu( table.getSelectedRowModel().rows.map((row) => row.original), ) } else { selectRow(row) - setContextMenuTargetItem([row.original]) + onContextMenu([row.original]) } } @@ -241,6 +264,7 @@ export function DirectoryContentTableContent() { table={table} row={row} onClick={() => selectRow(row)} + fileDragInfoAtom={fileDragInfoAtom} onContextMenu={(e) => handleRowContextMenu(row, e) } @@ -261,7 +285,7 @@ export function DirectoryContentTableContent() { function NoResultsRow() { return ( - + No results. @@ -274,22 +298,24 @@ function FileItemRow({ onClick, onContextMenu, onDoubleClick, + fileDragInfoAtom, }: { table: TableType row: Row onClick: () => void onContextMenu: (e: React.MouseEvent) => void onDoubleClick: () => void + fileDragInfoAtom: PrimitiveAtom }) { const ref = useRef(null) - const setDragInfo = useSetAtom(dragInfoAtom) + const setFileDragInfo = useSetAtom(fileDragInfoAtom) const { isDraggedOver, dropHandlers } = useFileDrop({ destItem: row.original.kind === FileType.Directory ? newDirectoryHandle(row.original.doc._id) : null, - dragInfoAtom, + dragInfoAtom: fileDragInfoAtom, }) const handleDragStart = (_e: React.DragEvent) => { @@ -316,14 +342,14 @@ function FileItemRow({ draggedItems = [source] } - setDragInfo({ + setFileDragInfo({ source, items: draggedItems, }) } const handleDragEnd = () => { - setDragInfo(null) + setFileDragInfo(null) } return ( @@ -367,9 +393,13 @@ function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) { ) } -function FileNameCell({ file }: { file: Doc<"files"> }) { - const setOpenedFile = useSetAtom(openedFileAtom) - +function FileNameCell({ + file, + onOpenFile, +}: { + file: Doc<"files"> + onOpenFile: (file: Doc<"files">) => void +}) { return (
@@ -377,7 +407,7 @@ function FileNameCell({ file }: { file: Doc<"files"> }) { type="button" className="hover:underline cursor-pointer" onClick={() => { - setOpenedFile(file) + onOpenFile(file) }} > {file.name} diff --git a/packages/web/src/directories/directory-page/directory-page.tsx b/packages/web/src/directories/directory-page/directory-page.tsx deleted file mode 100644 index 025114d..0000000 --- a/packages/web/src/directories/directory-page/directory-page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useAtom } from "jotai" -import { ImagePreviewDialog } from "@/components/image-preview-dialog" -import { DirectoryContentTable } from "./directory-content-table" -import { openedFileAtom } from "./state" - -export function DirectoryPage() { - return ( - <> -
- -
- - - ) -} - -function PreviewDialog() { - const [openedFile, setOpenedFile] = useAtom(openedFileAtom) - - if (!openedFile) return null - - switch (openedFile.mimeType) { - case "image/jpeg": - case "image/png": - case "image/gif": - return ( - setOpenedFile(null)} - /> - ) - default: - return null - } -} diff --git a/packages/web/src/files/file-preview-dialog.tsx b/packages/web/src/files/file-preview-dialog.tsx new file mode 100644 index 0000000..6c233cf --- /dev/null +++ b/packages/web/src/files/file-preview-dialog.tsx @@ -0,0 +1,21 @@ +import type { Doc } from "@fileone/convex/_generated/dataModel" +import { ImagePreviewDialog } from "./image-preview-dialog" + +export function FilePreviewDialog({ + file, + onClose, +}: { + file: Doc<"files"> + onClose: () => void +}) { + if (!file) return null + + switch (file.mimeType) { + case "image/jpeg": + case "image/png": + case "image/gif": + return + default: + return null + } +} diff --git a/packages/web/src/components/image-preview-dialog.tsx b/packages/web/src/files/image-preview-dialog.tsx similarity index 96% rename from packages/web/src/components/image-preview-dialog.tsx rename to packages/web/src/files/image-preview-dialog.tsx index b0bb40b..b85bc17 100644 --- a/packages/web/src/components/image-preview-dialog.tsx +++ b/packages/web/src/files/image-preview-dialog.tsx @@ -12,15 +12,15 @@ import { ZoomOutIcon, } from "lucide-react" import { useEffect, useRef } from "react" -import { Button } from "./ui/button" +import { Button } from "@/components/ui/button" import { Dialog, DialogClose, DialogContent, DialogHeader, DialogOverlay, -} from "./ui/dialog" -import { LoadingSpinner } from "./ui/loading-spinner" +} from "@/components/ui/dialog" +import { LoadingSpinner } from "@/components/ui/loading-spinner" const zoomLevelAtom = atom( 1, diff --git a/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx b/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx index 67cffa6..27f177f 100644 --- a/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx +++ b/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx @@ -1,23 +1,37 @@ import { api } from "@fileone/convex/_generated/api" -import { FileType } from "@fileone/convex/model/filesystem" +import type { Doc, Id } from "@fileone/convex/_generated/dataModel" +import { + type FileSystemItem, + FileType, + newFileSystemHandle, +} from "@fileone/convex/model/filesystem" import { useMutation } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" import { + useMutation as useContextMutation, useMutation as useConvexMutation, useQuery as useConvexQuery, } from "convex/react" -import { atom, useAtom } from "jotai" +import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { ChevronDownIcon, Loader2Icon, PlusIcon, + TextCursorInputIcon, + TrashIcon, UploadCloudIcon, } from "lucide-react" -import { type ChangeEvent, useContext, useRef } from "react" +import { type ChangeEvent, useCallback, useContext, useRef } from "react" import { toast } from "sonner" import { DirectoryIcon } from "@/components/icons/directory-icon" import { TextFileIcon } from "@/components/icons/text-file-icon" import { Button } from "@/components/ui/button" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu" import { DropdownMenu, DropdownMenuContent, @@ -26,11 +40,13 @@ import { } from "@/components/ui/dropdown-menu" import { WithAtom } from "@/components/with-atom" import { DirectoryPageContext } from "@/directories/directory-page/context" -import { DirectoryPage } from "@/directories/directory-page/directory-page" +import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table" import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton" import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb" import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog" import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog" +import { FilePreviewDialog } from "@/files/file-preview-dialog" +import type { FileDragInfo } from "@/files/use-file-drop" export const Route = createFileRoute( "/_authenticated/_sidebar-layout/directories/$directoryId", @@ -38,14 +54,27 @@ export const Route = createFileRoute( component: RouteComponent, }) +// MARK: atoms +const contextMenuTargetItemsAtom = atom([]) const newFileTypeAtom = atom(null) +const fileDragInfoAtom = atom(null) +const optimisticDeletedItemsAtom = atom( + new Set | Id<"directories">>(), +) +const openedFileAtom = atom | null>(null) +const itemBeingRenamedAtom = atom<{ + originalItem: FileSystemItem + name: string +} | null>(null) +// MARK: page entry function RouteComponent() { const { directoryId } = Route.useParams() const rootDirectory = useConvexQuery(api.files.fetchRootDirectory) const directory = useConvexQuery(api.files.fetchDirectory, { directoryId, }) + const store = useStore() const directoryContent = useConvexQuery( api.filesystem.fetchDirectoryContent, { @@ -53,6 +82,21 @@ function RouteComponent() { trashed: false, }, ) + const setOpenedFile = useSetAtom(openedFileAtom) + const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom) + + const tableFilter = useCallback( + (item: FileSystemItem) => + store.get(optimisticDeletedItemsAtom).has(item.doc._id), + [store], + ) + + const openFile = useCallback( + (file: Doc<"files">) => { + setOpenedFile(file) + }, + [setOpenedFile], + ) if (!directory || !directoryContent || !rootDirectory) { return @@ -70,7 +114,16 @@ function RouteComponent() {
- +
+ + + +
{(newFileType, setNewFileType) => ( @@ -87,10 +140,125 @@ function RouteComponent() { + + + {(openedFile, setOpenedFile) => { + if (!openedFile) return null + return ( + setOpenedFile(null)} + /> + ) + }} + ) } +// ================================== +// MARK: DirectoryContentContextMenu + +function DirectoryContentContextMenu({ + children, +}: { + children: React.ReactNode +}) { + const store = useStore() + const [target, setTarget] = useAtom(contextMenuTargetItemsAtom) + const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom) + const moveToTrashMutation = useContextMutation(api.filesystem.moveToTrash) + const { mutate: moveToTrash } = useMutation({ + mutationFn: moveToTrashMutation, + onMutate: ({ handles }) => { + setOptimisticDeletedItems( + (prev) => + new Set([...prev, ...handles.map((handle) => handle.id)]), + ) + }, + onSuccess: ({ deleted, errors }, { handles }) => { + setOptimisticDeletedItems((prev) => { + const newSet = new Set(prev) + for (const handle of handles) { + newSet.delete(handle.id) + } + return newSet + }) + if (errors.length === 0 && deleted.length === handles.length) { + toast.success(`Moved ${handles.length} items to trash`) + } else if (errors.length === handles.length) { + toast.error("Failed to move to trash") + } else { + toast.info( + `Moved ${deleted.length} items to trash; failed to move ${errors.length} items`, + ) + } + }, + }) + + const handleDelete = () => { + const selectedItems = store.get(contextMenuTargetItemsAtom) + if (selectedItems.length > 0) { + moveToTrash({ + handles: selectedItems.map(newFileSystemHandle), + }) + } + } + + return ( + { + if (!open) { + setTarget([]) + } + }} + > + {children} + {target && ( + + + + + Move to trash + + + )} + + ) +} + +function RenameMenuItem() { + const store = useStore() + const target = useAtomValue(contextMenuTargetItemsAtom) + const setItemBeingRenamed = useSetAtom(itemBeingRenamedAtom) + + const handleRename = () => { + const selectedItems = store.get(contextMenuTargetItemsAtom) + if (selectedItems.length === 1) { + // biome-ignore lint/style/noNonNullAssertion: length is checked + const selectedItem = selectedItems[0]! + setItemBeingRenamed({ + originalItem: selectedItem, + name: selectedItem.doc.name, + }) + } + } + + // Only render if exactly one item is selected + if (target.length !== 1) { + return null + } + + return ( + + + Rename + + ) +} + +// ================================== + // tags: upload, uploadfile, uploadfilebutton, fileupload, fileuploadbutton function UploadFileButton() { const { directory } = useContext(DirectoryPageContext) diff --git a/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx b/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx index 8517ba9..881ebca 100644 --- a/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx +++ b/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx @@ -4,7 +4,6 @@ import { useQuery as useConvexQuery } from "convex/react" import { TrashIcon } from "lucide-react" import { Button } from "@/components/ui/button" import { DirectoryPageContext } from "@/directories/directory-page/context" -import { DirectoryPage } from "@/directories/directory-page/directory-page" import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton" import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb" @@ -43,7 +42,7 @@ function RouteComponent() { - + {/* */} ) }