mirror of
https://github.com/get-drexa/drive.git
synced 2025-12-01 05:51:39 +00:00
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:
@@ -8,12 +8,14 @@ import type {
|
|||||||
FileHandle,
|
FileHandle,
|
||||||
FileSystemItem,
|
FileSystemItem,
|
||||||
} from "./model/filesystem"
|
} from "./model/filesystem"
|
||||||
|
import * as FileSystem from "./model/filesystem"
|
||||||
import {
|
import {
|
||||||
type FileSystemHandle,
|
type FileSystemHandle,
|
||||||
FileType,
|
FileType,
|
||||||
VDirectoryHandle,
|
VDirectoryHandle,
|
||||||
VFileSystemHandle,
|
VFileSystemHandle,
|
||||||
} from "./model/filesystem"
|
} from "./model/filesystem"
|
||||||
|
|
||||||
export const moveItems = authenticatedMutation({
|
export const moveItems = authenticatedMutation({
|
||||||
args: {
|
args: {
|
||||||
targetDirectory: VDirectoryHandle,
|
targetDirectory: VDirectoryHandle,
|
||||||
@@ -116,3 +118,12 @@ export const fetchDirectoryContent = authenticatedQuery({
|
|||||||
return await Directories.fetchContent(ctx, { directoryId, trashed })
|
return await Directories.fetchContent(ctx, { directoryId, trashed })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const permanentlyDeleteItems = authenticatedMutation({
|
||||||
|
args: {
|
||||||
|
handles: v.array(VFileSystemHandle),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { handles }) => {
|
||||||
|
return await FileSystem.deleteItemsPermanently(ctx, { handles })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -307,3 +307,43 @@ export async function moveToTrashRecursive(
|
|||||||
|
|
||||||
await Promise.all([...filePatches, ...directoryPatches])
|
await Promise.all([...filePatches, ...directoryPatches])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deletePermanently(
|
||||||
|
ctx: AuthenticatedMutationCtx,
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: DirectoryHandle[]
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsToBeDeleted = await Promise.allSettled(
|
||||||
|
items.map((item) => ctx.db.get(item.id)),
|
||||||
|
).then((results) =>
|
||||||
|
results.filter(
|
||||||
|
(result): result is PromiseFulfilledResult<Doc<"directories">> =>
|
||||||
|
result.status === "fulfilled" && result.value !== null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteDirectoryPromises = itemsToBeDeleted.map((item) =>
|
||||||
|
ctx.db.delete(item.value._id),
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteResults = await Promise.allSettled(deleteDirectoryPromises)
|
||||||
|
|
||||||
|
const errors: Err.ApplicationErrorData[] = []
|
||||||
|
let successfulDeletions = 0
|
||||||
|
for (const result of deleteResults) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
errors.push(Err.createJson(Err.Code.Internal))
|
||||||
|
} else {
|
||||||
|
successfulDeletions += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: successfulDeletions, errors }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Id } from "../_generated/dataModel"
|
import type { Doc, Id } from "../_generated/dataModel"
|
||||||
import type { AuthenticatedMutationCtx } from "../functions"
|
import type { AuthenticatedMutationCtx } from "../functions"
|
||||||
import * as Err from "./error"
|
import * as Err from "./error"
|
||||||
import type { DirectoryHandle, FileHandle } from "./filesystem"
|
import type { DirectoryHandle, FileHandle } from "./filesystem"
|
||||||
@@ -90,7 +90,10 @@ export async function move(
|
|||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
okFiles.map((handle) =>
|
okFiles.map((handle) =>
|
||||||
ctx.db.patch(handle.id, { directoryId: targetDirectoryHandle.id, updatedAt: Date.now() }),
|
ctx.db.patch(handle.id, {
|
||||||
|
directoryId: targetDirectoryHandle.id,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,3 +105,46 @@ export async function move(
|
|||||||
|
|
||||||
return { moved: okFiles, errors }
|
return { moved: okFiles, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deletePermanently(
|
||||||
|
ctx: AuthenticatedMutationCtx,
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: FileHandle[]
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsToBeDeleted = await Promise.allSettled(
|
||||||
|
items.map((item) => ctx.db.get(item.id)),
|
||||||
|
).then((results) =>
|
||||||
|
results.filter(
|
||||||
|
(result): result is PromiseFulfilledResult<Doc<"files">> =>
|
||||||
|
result.status === "fulfilled" && result.value !== null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteFilePromises = itemsToBeDeleted.map((item) =>
|
||||||
|
Promise.all([
|
||||||
|
ctx.db.delete(item.value._id),
|
||||||
|
ctx.storage.delete(item.value.storageId),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteResults = await Promise.allSettled(deleteFilePromises)
|
||||||
|
|
||||||
|
const errors: Err.ApplicationErrorData[] = []
|
||||||
|
let successfulDeletions = 0
|
||||||
|
for (const result of deleteResults) {
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
errors.push(Err.createJson(Err.Code.Internal))
|
||||||
|
} else {
|
||||||
|
successfulDeletions += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: successfulDeletions, errors }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
import type { Doc, Id } from "../_generated/dataModel"
|
import type { Doc, Id } from "../_generated/dataModel"
|
||||||
|
import type { AuthenticatedMutationCtx } from "../functions"
|
||||||
|
import * as Directories from "./directories"
|
||||||
|
import * as Files from "./files"
|
||||||
|
|
||||||
export enum FileType {
|
export enum FileType {
|
||||||
File = "File",
|
File = "File",
|
||||||
@@ -73,3 +76,84 @@ export const VFileHandle = v.object({
|
|||||||
id: v.id("files"),
|
id: v.id("files"),
|
||||||
})
|
})
|
||||||
export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle)
|
export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle)
|
||||||
|
|
||||||
|
export async function deleteItemsPermanently(
|
||||||
|
ctx: AuthenticatedMutationCtx,
|
||||||
|
{ handles }: { handles: FileSystemHandle[] },
|
||||||
|
) {
|
||||||
|
// Collect all items to delete (including nested items)
|
||||||
|
const fileHandlesToDelete: FileHandle[] = []
|
||||||
|
const directoryHandlesToDelete: DirectoryHandle[] = []
|
||||||
|
|
||||||
|
// Process each handle to collect files and directories
|
||||||
|
for (const handle of handles) {
|
||||||
|
// Use a queue to process items iteratively instead of recursively
|
||||||
|
const queue: FileSystemHandle[] = [handle]
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentHandle = queue.shift()!
|
||||||
|
|
||||||
|
// Add current item to appropriate deletion collection
|
||||||
|
if (currentHandle.kind === FileType.File) {
|
||||||
|
fileHandlesToDelete.push(currentHandle)
|
||||||
|
} else {
|
||||||
|
directoryHandlesToDelete.push(currentHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a directory, collect all children and add them to the queue
|
||||||
|
if (currentHandle.kind === FileType.Directory) {
|
||||||
|
// Get all child directories that are in trash (deletedAt > 0)
|
||||||
|
const childDirectories = await ctx.db
|
||||||
|
.query("directories")
|
||||||
|
.withIndex("byParentId", (q) =>
|
||||||
|
q
|
||||||
|
.eq("userId", ctx.user._id)
|
||||||
|
.eq("parentId", currentHandle.id)
|
||||||
|
.gte("deletedAt", 0),
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
// Get all child files that are in trash (deletedAt > 0)
|
||||||
|
const childFiles = await ctx.db
|
||||||
|
.query("files")
|
||||||
|
.withIndex("byDirectoryId", (q) =>
|
||||||
|
q
|
||||||
|
.eq("userId", ctx.user._id)
|
||||||
|
.eq("directoryId", currentHandle.id)
|
||||||
|
.gte("deletedAt", 0),
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
// Add child directories to queue for processing
|
||||||
|
for (const childDir of childDirectories) {
|
||||||
|
const childHandle = newDirectoryHandle(childDir._id)
|
||||||
|
queue.push(childHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add child files to file handles collection
|
||||||
|
for (const childFile of childFiles) {
|
||||||
|
const childFileHandle = newFileHandle(childFile._id)
|
||||||
|
fileHandlesToDelete.push(childFileHandle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete files and directories using their respective models
|
||||||
|
const [filesResult, directoriesResult] = await Promise.all([
|
||||||
|
Files.deletePermanently(ctx, { items: fileHandlesToDelete }),
|
||||||
|
Directories.deletePermanently(ctx, { items: directoryHandlesToDelete }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Combine results, handling null responses
|
||||||
|
return {
|
||||||
|
deleted: {
|
||||||
|
files: filesResult?.deleted || 0,
|
||||||
|
directories: directoriesResult?.deleted || 0,
|
||||||
|
},
|
||||||
|
errors: [
|
||||||
|
...(filesResult?.errors || []),
|
||||||
|
...(directoriesResult?.errors || []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { DirectoryPageContext } from "./context"
|
|||||||
|
|
||||||
type DirectoryContentTableProps = {
|
type DirectoryContentTableProps = {
|
||||||
filterFn: (item: FileSystemItem) => boolean
|
filterFn: (item: FileSystemItem) => boolean
|
||||||
|
directoryUrlFn: (directory: Doc<"directories">) => string
|
||||||
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
|
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
|
||||||
onContextMenu: (
|
onContextMenu: (
|
||||||
row: Row<FileSystemItem>,
|
row: Row<FileSystemItem>,
|
||||||
@@ -62,6 +63,7 @@ function formatFileSize(bytes: number): string {
|
|||||||
|
|
||||||
function useTableColumns(
|
function useTableColumns(
|
||||||
onOpenFile: (file: Doc<"files">) => void,
|
onOpenFile: (file: Doc<"files">) => void,
|
||||||
|
directoryUrlFn: (directory: Doc<"directories">) => string,
|
||||||
): ColumnDef<FileSystemItem>[] {
|
): ColumnDef<FileSystemItem>[] {
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -106,6 +108,7 @@ function useTableColumns(
|
|||||||
return (
|
return (
|
||||||
<DirectoryNameCell
|
<DirectoryNameCell
|
||||||
directory={row.original.doc}
|
directory={row.original.doc}
|
||||||
|
directoryUrlFn={directoryUrlFn}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,12 +145,13 @@ function useTableColumns(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[onOpenFile],
|
[onOpenFile, directoryUrlFn],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DirectoryContentTable({
|
export function DirectoryContentTable({
|
||||||
filterFn,
|
filterFn,
|
||||||
|
directoryUrlFn,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
fileDragInfoAtom,
|
fileDragInfoAtom,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
@@ -158,7 +162,7 @@ export function DirectoryContentTable({
|
|||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: directoryContent || [],
|
data: directoryContent || [],
|
||||||
columns: useTableColumns(onOpenFile),
|
columns: useTableColumns(onOpenFile, directoryUrlFn),
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
enableGlobalFilter: 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 (
|
return (
|
||||||
<div className="flex w-full items-center gap-2">
|
<div className="flex w-full items-center gap-2">
|
||||||
<DirectoryIcon className="size-4" />
|
<DirectoryIcon className="size-4" />
|
||||||
<Link
|
<Link className="hover:underline" to={directoryUrlFn(directory)}>
|
||||||
className="hover:underline"
|
|
||||||
to={`/directories/${directory._id}`}
|
|
||||||
>
|
|
||||||
{directory.name}
|
{directory.name}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ function RouteComponent() {
|
|||||||
[setOpenedFile],
|
[setOpenedFile],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const directoryUrlFn = useCallback(
|
||||||
|
(directory: Doc<"directories">) => `/directories/${directory._id}`,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const handleContextMenuRequest = (
|
const handleContextMenuRequest = (
|
||||||
row: Row<FileSystemItem>,
|
row: Row<FileSystemItem>,
|
||||||
table: Table<FileSystemItem>,
|
table: Table<FileSystemItem>,
|
||||||
@@ -133,6 +138,7 @@ function RouteComponent() {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<DirectoryContentTable
|
<DirectoryContentTable
|
||||||
filterFn={tableFilter}
|
filterFn={tableFilter}
|
||||||
|
directoryUrlFn={directoryUrlFn}
|
||||||
fileDragInfoAtom={fileDragInfoAtom}
|
fileDragInfoAtom={fileDragInfoAtom}
|
||||||
onContextMenu={handleContextMenuRequest}
|
onContextMenu={handleContextMenuRequest}
|
||||||
onOpenFile={openFile}
|
onOpenFile={openFile}
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
import { api } from "@fileone/convex/_generated/api"
|
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 { createFileRoute } from "@tanstack/react-router"
|
||||||
import { useQuery as useConvexQuery } from "convex/react"
|
import type { Row, Table } from "@tanstack/react-table"
|
||||||
import { TrashIcon } from "lucide-react"
|
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 { 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 { 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 { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton"
|
||||||
import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb"
|
import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-breadcrumb"
|
||||||
|
import type { FileDragInfo } from "@/files/use-file-drop"
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
"/_authenticated/_sidebar-layout/trash/directories/$directoryId",
|
"/_authenticated/_sidebar-layout/trash/directories/$directoryId",
|
||||||
@@ -13,6 +42,13 @@ export const Route = createFileRoute(
|
|||||||
component: RouteComponent,
|
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() {
|
function RouteComponent() {
|
||||||
const { directoryId } = Route.useParams()
|
const { directoryId } = Route.useParams()
|
||||||
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
|
const rootDirectory = useConvexQuery(api.files.fetchRootDirectory)
|
||||||
@@ -26,11 +62,31 @@ function RouteComponent() {
|
|||||||
trashed: true,
|
trashed: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom)
|
||||||
|
|
||||||
|
const directoryUrlFn = useCallback(
|
||||||
|
(directory: Doc<"directories">) =>
|
||||||
|
`/trash/directories/${directory._id}`,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
if (!directory || !directoryContent || !rootDirectory) {
|
if (!directory || !directoryContent || !rootDirectory) {
|
||||||
return <DirectoryPageSkeleton />
|
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 (
|
return (
|
||||||
<DirectoryPageContext
|
<DirectoryPageContext
|
||||||
value={{ rootDirectory, directory, directoryContent }}
|
value={{ rootDirectory, directory, directoryContent }}
|
||||||
@@ -42,16 +98,148 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* <DirectoryPage /> */}
|
<TableContextMenu>
|
||||||
|
<div className="w-full">
|
||||||
|
<DirectoryContentTable
|
||||||
|
filterFn={() => true}
|
||||||
|
directoryUrlFn={directoryUrlFn}
|
||||||
|
fileDragInfoAtom={fileDragInfoAtom}
|
||||||
|
onContextMenu={handleContextMenuRequest}
|
||||||
|
onOpenFile={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableContextMenu>
|
||||||
|
|
||||||
|
<DeleteConfirmationDialog />
|
||||||
</DirectoryPageContext>
|
</DirectoryPageContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyTrashButton() {
|
function TableContextMenu({ children }: React.PropsWithChildren) {
|
||||||
|
const setIsDeleteConfirmationDialogOpen = useSetAtom(
|
||||||
|
isDeleteConfirmationDialogOpenAtom,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
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" />
|
<TrashIcon className="size-4" />
|
||||||
Empty Trash
|
Empty Trash
|
||||||
</Button>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user