mirror of
https://github.com/get-drexa/drive.git
synced 2025-11-30 21:41:39 +00:00
feat: implement empty trash
This commit is contained in:
@@ -128,6 +128,12 @@ export const permanentlyDeleteItems = authenticatedMutation({
|
||||
},
|
||||
})
|
||||
|
||||
export const emptyTrash = authenticatedMutation({
|
||||
handler: async (ctx) => {
|
||||
return await FileSystem.emptyTrash(ctx)
|
||||
},
|
||||
})
|
||||
|
||||
export const restoreItems = authenticatedMutation({
|
||||
args: {
|
||||
handles: v.array(VFileSystemHandle),
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum Code {
|
||||
FileNotFound = "FileNotFound",
|
||||
Internal = "Internal",
|
||||
Unauthenticated = "Unauthenticated",
|
||||
NotFound = "NotFound",
|
||||
}
|
||||
|
||||
export type ApplicationErrorData = { code: Code; message?: string }
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { v } from "convex/values"
|
||||
import type { Doc, Id } from "../_generated/dataModel"
|
||||
import type { AuthenticatedMutationCtx } from "../functions"
|
||||
import type {
|
||||
AuthenticatedMutationCtx,
|
||||
AuthenticatedQueryCtx,
|
||||
} from "../functions"
|
||||
import * as Directories from "./directories"
|
||||
import * as Err from "./error"
|
||||
import * as Files from "./files"
|
||||
@@ -47,6 +50,14 @@ export type FileHandle = {
|
||||
}
|
||||
export type FileSystemHandle = DirectoryHandle | FileHandle
|
||||
|
||||
export type DeleteResult = {
|
||||
deleted: {
|
||||
files: number
|
||||
directories: number
|
||||
}
|
||||
errors: Err.ApplicationErrorData[]
|
||||
}
|
||||
|
||||
export function newFileSystemHandle(item: FileSystemItem): FileSystemHandle {
|
||||
console.log("item", item)
|
||||
switch (item.kind) {
|
||||
@@ -82,15 +93,21 @@ export const VFileHandle = v.object({
|
||||
})
|
||||
export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle)
|
||||
|
||||
export async function ensureRootDirectory(
|
||||
ctx: AuthenticatedMutationCtx,
|
||||
): Promise<Id<"directories">> {
|
||||
const existing = await ctx.db
|
||||
export async function queryRootDirectory(
|
||||
ctx: AuthenticatedQueryCtx,
|
||||
): Promise<Doc<"directories"> | null> {
|
||||
return await ctx.db
|
||||
.query("directories")
|
||||
.withIndex("byParentId", (q) =>
|
||||
q.eq("userId", ctx.user._id).eq("parentId", undefined),
|
||||
)
|
||||
.first()
|
||||
}
|
||||
|
||||
export async function ensureRootDirectory(
|
||||
ctx: AuthenticatedMutationCtx,
|
||||
): Promise<Id<"directories">> {
|
||||
const existing = await queryRootDirectory(ctx)
|
||||
|
||||
if (existing) {
|
||||
return existing._id
|
||||
@@ -207,7 +224,7 @@ export async function restoreItems(
|
||||
export async function deleteItemsPermanently(
|
||||
ctx: AuthenticatedMutationCtx,
|
||||
{ handles }: { handles: FileSystemHandle[] },
|
||||
) {
|
||||
): Promise<DeleteResult> {
|
||||
// Collect all items to delete (including nested items)
|
||||
const {
|
||||
fileHandles: fileHandlesToDelete,
|
||||
@@ -232,3 +249,49 @@ export async function deleteItemsPermanently(
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function emptyTrash(
|
||||
ctx: AuthenticatedMutationCtx,
|
||||
): Promise<DeleteResult> {
|
||||
const rootDir = await queryRootDirectory(ctx)
|
||||
if (!rootDir) {
|
||||
throw Err.create(Err.Code.NotFound, "user root directory not found")
|
||||
}
|
||||
|
||||
const dirs = await ctx.db
|
||||
.query("directories")
|
||||
.withIndex("byParentId", (q) =>
|
||||
q
|
||||
.eq("userId", ctx.user._id)
|
||||
.eq("parentId", rootDir._id)
|
||||
.gte("deletedAt", 0),
|
||||
)
|
||||
.collect()
|
||||
|
||||
const files = await ctx.db
|
||||
.query("files")
|
||||
.withIndex("byDirectoryId", (q) =>
|
||||
q
|
||||
.eq("userId", ctx.user._id)
|
||||
.eq("directoryId", rootDir._id)
|
||||
.gte("deletedAt", 0),
|
||||
)
|
||||
.collect()
|
||||
|
||||
if (dirs.length === 0 && files.length === 0) {
|
||||
return {
|
||||
deleted: {
|
||||
files: 0,
|
||||
directories: 0,
|
||||
},
|
||||
errors: [],
|
||||
}
|
||||
}
|
||||
|
||||
return await deleteItemsPermanently(ctx, {
|
||||
handles: [
|
||||
...dirs.map((it) => newDirectoryHandle(it._id)),
|
||||
...files.map((it) => newFileHandle(it._id)),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { api } from "@fileone/convex/_generated/api"
|
||||
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"
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
} from "convex/react"
|
||||
import { atom, useAtom, useSetAtom, useStore } from "jotai"
|
||||
import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useCallback, useContext, useEffect } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -45,9 +46,14 @@ export const Route = createFileRoute(
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
enum ActiveDialogKind {
|
||||
DeleteConfirmation = "DeleteConfirmation",
|
||||
EmptyTrashConfirmation = "EmptyTrashConfirmation",
|
||||
}
|
||||
|
||||
const contextMenuTargetItemsAtom = atom<FileSystemItem[]>([])
|
||||
const fileDragInfoAtom = atom<FileDragInfo | null>(null)
|
||||
const isDeleteConfirmationDialogOpenAtom = atom(false)
|
||||
const activeDialogAtom = atom<ActiveDialogKind | null>(null)
|
||||
const openedFileAtom = atom<Doc<"files"> | null>(null)
|
||||
const optimisticRemovedItemsAtom = atom(
|
||||
new Set<Id<"files"> | Id<"directories">>(),
|
||||
@@ -124,6 +130,7 @@ function RouteComponent() {
|
||||
</TableContextMenu>
|
||||
|
||||
<DeleteConfirmationDialog />
|
||||
<EmptyTrashConfirmationDialog />
|
||||
|
||||
<WithAtom atom={openedFileAtom}>
|
||||
{(openedFile, setOpenedFile) => {
|
||||
@@ -141,9 +148,7 @@ function RouteComponent() {
|
||||
}
|
||||
|
||||
function TableContextMenu({ children }: React.PropsWithChildren) {
|
||||
const setIsDeleteConfirmationDialogOpen = useSetAtom(
|
||||
isDeleteConfirmationDialogOpenAtom,
|
||||
)
|
||||
const setActiveDialog = useSetAtom(activeDialogAtom)
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -153,7 +158,7 @@ function TableContextMenu({ children }: React.PropsWithChildren) {
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setIsDeleteConfirmationDialogOpen(true)
|
||||
setActiveDialog(ActiveDialogKind.DeleteConfirmation)
|
||||
}}
|
||||
>
|
||||
<ShredderIcon />
|
||||
@@ -223,9 +228,7 @@ function RestoreContextMenuItem() {
|
||||
}
|
||||
|
||||
function EmptyTrashButton() {
|
||||
const setIsDeleteConfirmationDialogOpen = useSetAtom(
|
||||
isDeleteConfirmationDialogOpenAtom,
|
||||
)
|
||||
const setActiveDialog = useSetAtom(activeDialogAtom)
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -233,19 +236,19 @@ function EmptyTrashButton() {
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setIsDeleteConfirmationDialogOpen(true)
|
||||
setActiveDialog(ActiveDialogKind.EmptyTrashConfirmation)
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
Empty Trash
|
||||
Empty trash
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteConfirmationDialog() {
|
||||
const { rootDirectory } = useContext(DirectoryPageContext)
|
||||
const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom)
|
||||
const [targetItems, setTargetItems] = useAtom(contextMenuTargetItemsAtom)
|
||||
const [isDeleteConfirmationDialogOpen, setIsDeleteConfirmationDialogOpen] =
|
||||
useAtom(isDeleteConfirmationDialogOpenAtom)
|
||||
const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom)
|
||||
|
||||
const deletePermanentlyMutation = useConvexMutation(
|
||||
@@ -276,21 +279,37 @@ function DeleteConfirmationDialog() {
|
||||
`Deleted ${deleted.files} files and ${deleted.directories} directories; failed to delete ${errors.length} items`,
|
||||
)
|
||||
}
|
||||
setIsDeleteConfirmationDialogOpen(false)
|
||||
setActiveDialog(null)
|
||||
setTargetItems([])
|
||||
},
|
||||
})
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setActiveDialog(ActiveDialogKind.DeleteConfirmation)
|
||||
} else {
|
||||
setActiveDialog(null)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
deletePermanently({
|
||||
handles: targetItems.map(newFileSystemHandle),
|
||||
handles:
|
||||
targetItems.length > 0
|
||||
? targetItems.map(newFileSystemHandle)
|
||||
: [
|
||||
newFileSystemHandle({
|
||||
kind: FileType.Directory,
|
||||
doc: rootDirectory,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isDeleteConfirmationDialogOpen}
|
||||
onOpenChange={setIsDeleteConfirmationDialogOpen}
|
||||
open={activeDialog === ActiveDialogKind.DeleteConfirmation}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -323,3 +342,61 @@ function DeleteConfirmationDialog() {
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTrashConfirmationDialog() {
|
||||
const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom)
|
||||
|
||||
const { mutate: emptyTrash, isPending: isEmptying } = useMutation({
|
||||
mutationFn: useConvexMutation(api.filesystem.emptyTrash),
|
||||
onSuccess: () => {
|
||||
toast.success("Trash emptied successfully")
|
||||
setActiveDialog(null)
|
||||
},
|
||||
})
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
setActiveDialog(ActiveDialogKind.EmptyTrashConfirmation)
|
||||
} else {
|
||||
setActiveDialog(null)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmEmpty() {
|
||||
emptyTrash(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={activeDialog === ActiveDialogKind.EmptyTrashConfirmation}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Empty your trash?</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p>
|
||||
All items in the trash will be permanently deleted. They
|
||||
will be IRRECOVERABLE.
|
||||
</p>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={isEmptying}>
|
||||
No, go back
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmEmpty}
|
||||
disabled={isEmptying}
|
||||
loading={isEmptying}
|
||||
>
|
||||
Yes, empty trash
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user