impl: permanent file deletion

implement trash page and permanent file deletion logic

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-10-05 00:41:59 +00:00
parent e806d442b7
commit 19e52feebb
7 changed files with 396 additions and 14 deletions

View File

@@ -42,6 +42,7 @@ import { DirectoryPageContext } from "./context"
type DirectoryContentTableProps = {
filterFn: (item: FileSystemItem) => boolean
directoryUrlFn: (directory: Doc<"directories">) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: (
row: Row<FileSystemItem>,
@@ -62,6 +63,7 @@ function formatFileSize(bytes: number): string {
function useTableColumns(
onOpenFile: (file: Doc<"files">) => void,
directoryUrlFn: (directory: Doc<"directories">) => string,
): ColumnDef<FileSystemItem>[] {
return useMemo(
() => [
@@ -106,6 +108,7 @@ function useTableColumns(
return (
<DirectoryNameCell
directory={row.original.doc}
directoryUrlFn={directoryUrlFn}
/>
)
}
@@ -142,12 +145,13 @@ function useTableColumns(
},
},
],
[onOpenFile],
[onOpenFile, directoryUrlFn],
)
}
export function DirectoryContentTable({
filterFn,
directoryUrlFn,
onContextMenu,
fileDragInfoAtom,
onOpenFile,
@@ -158,7 +162,7 @@ export function DirectoryContentTable({
const table = useReactTable({
data: directoryContent || [],
columns: useTableColumns(onOpenFile),
columns: useTableColumns(onOpenFile, directoryUrlFn),
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
enableGlobalFilter: true,
@@ -366,14 +370,17 @@ function FileItemRow({
)
}
function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) {
function DirectoryNameCell({
directory,
directoryUrlFn,
}: {
directory: Doc<"directories">
directoryUrlFn: (directory: Doc<"directories">) => string
}) {
return (
<div className="flex w-full items-center gap-2">
<DirectoryIcon className="size-4" />
<Link
className="hover:underline"
to={`/directories/${directory._id}`}
>
<Link className="hover:underline" to={directoryUrlFn(directory)}>
{directory.name}
</Link>
</div>

View File

@@ -99,6 +99,11 @@ function RouteComponent() {
[setOpenedFile],
)
const directoryUrlFn = useCallback(
(directory: Doc<"directories">) => `/directories/${directory._id}`,
[],
)
const handleContextMenuRequest = (
row: Row<FileSystemItem>,
table: Table<FileSystemItem>,
@@ -133,6 +138,7 @@ function RouteComponent() {
<div className="w-full">
<DirectoryContentTable
filterFn={tableFilter}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={openFile}

View File

@@ -1,11 +1,40 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import {
type FileSystemItem,
newFileSystemHandle,
} from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { useQuery as useConvexQuery } from "convex/react"
import { TrashIcon } from "lucide-react"
import type { Row, Table } from "@tanstack/react-table"
import {
useMutation as useConvexMutation,
useQuery as useConvexQuery,
} from "convex/react"
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react"
import { useCallback } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { DirectoryPageContext } from "@/directories/directory-page/context"
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 type { FileDragInfo } from "@/files/use-file-drop"
export const Route = createFileRoute(
"/_authenticated/_sidebar-layout/trash/directories/$directoryId",
@@ -13,6 +42,13 @@ export const Route = createFileRoute(
component: RouteComponent,
})
const contextMenuTargetItemsAtom = atom<FileSystemItem[]>([])
const fileDragInfoAtom = atom<FileDragInfo | null>(null)
const isDeleteConfirmationDialogOpenAtom = atom(false)
const optimisticRemovedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
function RouteComponent() {
const { directoryId } = Route.useParams()
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
@@ -26,11 +62,31 @@ function RouteComponent() {
trashed: true,
},
)
const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
const directoryUrlFn = useCallback(
(directory: Doc<"directories">) =>
`/trash/directories/${directory._id}`,
[],
)
if (!directory || !directoryContent || !rootDirectory) {
return <DirectoryPageSkeleton />
}
const handleContextMenuRequest = (
row: Row<FileSystemItem>,
table: Table<FileSystemItem>,
) => {
if (row.getIsSelected()) {
setContextMenuTargetItems(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
setContextMenuTargetItems([row.original])
}
}
return (
<DirectoryPageContext
value={{ rootDirectory, directory, directoryContent }}
@@ -42,16 +98,148 @@ function RouteComponent() {
</div>
</header>
{/* <DirectoryPage /> */}
<TableContextMenu>
<div className="w-full">
<DirectoryContentTable
filterFn={() => true}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={() => {}}
/>
</div>
</TableContextMenu>
<DeleteConfirmationDialog />
</DirectoryPageContext>
)
}
function EmptyTrashButton() {
function TableContextMenu({ children }: React.PropsWithChildren) {
const setIsDeleteConfirmationDialogOpen = useSetAtom(
isDeleteConfirmationDialogOpenAtom,
)
return (
<Button size="sm" type="button" variant="outline">
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<UndoIcon />
Restore
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={() => {
setIsDeleteConfirmationDialogOpen(true)
}}
>
<ShredderIcon />
Delete permanently
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
function EmptyTrashButton() {
const setIsDeleteConfirmationDialogOpen = useSetAtom(
isDeleteConfirmationDialogOpenAtom,
)
return (
<Button
size="sm"
type="button"
variant="outline"
onClick={() => {
setIsDeleteConfirmationDialogOpen(true)
}}
>
<TrashIcon className="size-4" />
Empty Trash
</Button>
)
}
function DeleteConfirmationDialog() {
const [targetItems, setTargetItems] = useAtom(contextMenuTargetItemsAtom)
const [isDeleteConfirmationDialogOpen, setIsDeleteConfirmationDialogOpen] =
useAtom(isDeleteConfirmationDialogOpenAtom)
const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom)
const deletePermanentlyMutation = useConvexMutation(
api.filesystem.permanentlyDeleteItems,
)
const { mutate: deletePermanently, isPending: isDeleting } = useMutation({
mutationFn: deletePermanentlyMutation,
onMutate: ({ handles }) => {
setOptimisticRemovedItems(
(prev) =>
new Set([...prev, ...handles.map((handle) => handle.id)]),
)
},
onSuccess: ({ deleted, errors }, { handles }) => {
setOptimisticRemovedItems((prev) => {
const newSet = new Set(prev)
for (const handle of handles) {
newSet.delete(handle.id)
}
return newSet
})
if (errors.length === 0) {
toast.success(
`Deleted ${deleted.files} files and ${deleted.directories} directories`,
)
} else {
toast.warning(
`Deleted ${deleted.files} files and ${deleted.directories} directories; failed to delete ${errors.length} items`,
)
}
setIsDeleteConfirmationDialogOpen(false)
setTargetItems([])
},
})
const confirmDelete = () => {
deletePermanently({
handles: targetItems.map(newFileSystemHandle),
})
}
return (
<Dialog
open={isDeleteConfirmationDialogOpen}
onOpenChange={setIsDeleteConfirmationDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Permanently delete {targetItems.length} items?
</DialogTitle>
</DialogHeader>
<p>
{targetItems.length} items will be permanently deleted. They
will be IRRECOVERABLE.
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={isDeleting}>
Go back
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={isDeleting}
loading={isDeleting}
>
Yes, delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}