feat: implement empty trash

This commit is contained in:
2025-10-12 14:31:02 +00:00
parent 5eff2fa756
commit 03d36a2c80
4 changed files with 170 additions and 23 deletions

View File

@@ -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),

View File

@@ -8,6 +8,7 @@ export enum Code {
FileNotFound = "FileNotFound",
Internal = "Internal",
Unauthenticated = "Unauthenticated",
NotFound = "NotFound",
}
export type ApplicationErrorData = { code: Code; message?: string }

View File

@@ -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)),
],
})
}

View File

@@ -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>
)
}