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({ export const restoreItems = authenticatedMutation({
args: { args: {
handles: v.array(VFileSystemHandle), handles: v.array(VFileSystemHandle),

View File

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

View File

@@ -1,6 +1,9 @@
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 type {
AuthenticatedMutationCtx,
AuthenticatedQueryCtx,
} from "../functions"
import * as Directories from "./directories" import * as Directories from "./directories"
import * as Err from "./error" import * as Err from "./error"
import * as Files from "./files" import * as Files from "./files"
@@ -47,6 +50,14 @@ export type FileHandle = {
} }
export type FileSystemHandle = DirectoryHandle | FileHandle export type FileSystemHandle = DirectoryHandle | FileHandle
export type DeleteResult = {
deleted: {
files: number
directories: number
}
errors: Err.ApplicationErrorData[]
}
export function newFileSystemHandle(item: FileSystemItem): FileSystemHandle { export function newFileSystemHandle(item: FileSystemItem): FileSystemHandle {
console.log("item", item) console.log("item", item)
switch (item.kind) { switch (item.kind) {
@@ -82,15 +93,21 @@ export const VFileHandle = v.object({
}) })
export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle) export const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle)
export async function ensureRootDirectory( export async function queryRootDirectory(
ctx: AuthenticatedMutationCtx, ctx: AuthenticatedQueryCtx,
): Promise<Id<"directories">> { ): Promise<Doc<"directories"> | null> {
const existing = await ctx.db return await ctx.db
.query("directories") .query("directories")
.withIndex("byParentId", (q) => .withIndex("byParentId", (q) =>
q.eq("userId", ctx.user._id).eq("parentId", undefined), q.eq("userId", ctx.user._id).eq("parentId", undefined),
) )
.first() .first()
}
export async function ensureRootDirectory(
ctx: AuthenticatedMutationCtx,
): Promise<Id<"directories">> {
const existing = await queryRootDirectory(ctx)
if (existing) { if (existing) {
return existing._id return existing._id
@@ -207,7 +224,7 @@ export async function restoreItems(
export async function deleteItemsPermanently( export async function deleteItemsPermanently(
ctx: AuthenticatedMutationCtx, ctx: AuthenticatedMutationCtx,
{ handles }: { handles: FileSystemHandle[] }, { handles }: { handles: FileSystemHandle[] },
) { ): Promise<DeleteResult> {
// Collect all items to delete (including nested items) // Collect all items to delete (including nested items)
const { const {
fileHandles: fileHandlesToDelete, 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 { Doc, Id } from "@fileone/convex/_generated/dataModel"
import { import {
type FileSystemItem, type FileSystemItem,
FileType,
newFileSystemHandle, newFileSystemHandle,
} from "@fileone/convex/model/filesystem" } from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
@@ -13,7 +14,7 @@ import {
} from "convex/react" } from "convex/react"
import { atom, useAtom, useSetAtom, useStore } from "jotai" import { atom, useAtom, useSetAtom, useStore } from "jotai"
import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react" import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react"
import { useCallback, useEffect } from "react" import { useCallback, useContext, useEffect } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@@ -45,9 +46,14 @@ export const Route = createFileRoute(
component: RouteComponent, component: RouteComponent,
}) })
enum ActiveDialogKind {
DeleteConfirmation = "DeleteConfirmation",
EmptyTrashConfirmation = "EmptyTrashConfirmation",
}
const contextMenuTargetItemsAtom = atom<FileSystemItem[]>([]) const contextMenuTargetItemsAtom = atom<FileSystemItem[]>([])
const fileDragInfoAtom = atom<FileDragInfo | null>(null) const fileDragInfoAtom = atom<FileDragInfo | null>(null)
const isDeleteConfirmationDialogOpenAtom = atom(false) const activeDialogAtom = atom<ActiveDialogKind | null>(null)
const openedFileAtom = atom<Doc<"files"> | null>(null) const openedFileAtom = atom<Doc<"files"> | null>(null)
const optimisticRemovedItemsAtom = atom( const optimisticRemovedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(), new Set<Id<"files"> | Id<"directories">>(),
@@ -124,6 +130,7 @@ function RouteComponent() {
</TableContextMenu> </TableContextMenu>
<DeleteConfirmationDialog /> <DeleteConfirmationDialog />
<EmptyTrashConfirmationDialog />
<WithAtom atom={openedFileAtom}> <WithAtom atom={openedFileAtom}>
{(openedFile, setOpenedFile) => { {(openedFile, setOpenedFile) => {
@@ -141,9 +148,7 @@ function RouteComponent() {
} }
function TableContextMenu({ children }: React.PropsWithChildren) { function TableContextMenu({ children }: React.PropsWithChildren) {
const setIsDeleteConfirmationDialogOpen = useSetAtom( const setActiveDialog = useSetAtom(activeDialogAtom)
isDeleteConfirmationDialogOpenAtom,
)
return ( return (
<ContextMenu> <ContextMenu>
@@ -153,7 +158,7 @@ function TableContextMenu({ children }: React.PropsWithChildren) {
<ContextMenuItem <ContextMenuItem
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
setIsDeleteConfirmationDialogOpen(true) setActiveDialog(ActiveDialogKind.DeleteConfirmation)
}} }}
> >
<ShredderIcon /> <ShredderIcon />
@@ -223,9 +228,7 @@ function RestoreContextMenuItem() {
} }
function EmptyTrashButton() { function EmptyTrashButton() {
const setIsDeleteConfirmationDialogOpen = useSetAtom( const setActiveDialog = useSetAtom(activeDialogAtom)
isDeleteConfirmationDialogOpenAtom,
)
return ( return (
<Button <Button
@@ -233,19 +236,19 @@ function EmptyTrashButton() {
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
setIsDeleteConfirmationDialogOpen(true) setActiveDialog(ActiveDialogKind.EmptyTrashConfirmation)
}} }}
> >
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />
Empty Trash Empty trash
</Button> </Button>
) )
} }
function DeleteConfirmationDialog() { function DeleteConfirmationDialog() {
const { rootDirectory } = useContext(DirectoryPageContext)
const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom)
const [targetItems, setTargetItems] = useAtom(contextMenuTargetItemsAtom) const [targetItems, setTargetItems] = useAtom(contextMenuTargetItemsAtom)
const [isDeleteConfirmationDialogOpen, setIsDeleteConfirmationDialogOpen] =
useAtom(isDeleteConfirmationDialogOpenAtom)
const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom) const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom)
const deletePermanentlyMutation = useConvexMutation( const deletePermanentlyMutation = useConvexMutation(
@@ -276,21 +279,37 @@ function DeleteConfirmationDialog() {
`Deleted ${deleted.files} files and ${deleted.directories} directories; failed to delete ${errors.length} items`, `Deleted ${deleted.files} files and ${deleted.directories} directories; failed to delete ${errors.length} items`,
) )
} }
setIsDeleteConfirmationDialogOpen(false) setActiveDialog(null)
setTargetItems([]) setTargetItems([])
}, },
}) })
const onOpenChange = (open: boolean) => {
if (open) {
setActiveDialog(ActiveDialogKind.DeleteConfirmation)
} else {
setActiveDialog(null)
}
}
const confirmDelete = () => { const confirmDelete = () => {
deletePermanently({ deletePermanently({
handles: targetItems.map(newFileSystemHandle), handles:
targetItems.length > 0
? targetItems.map(newFileSystemHandle)
: [
newFileSystemHandle({
kind: FileType.Directory,
doc: rootDirectory,
}),
],
}) })
} }
return ( return (
<Dialog <Dialog
open={isDeleteConfirmationDialogOpen} open={activeDialog === ActiveDialogKind.DeleteConfirmation}
onOpenChange={setIsDeleteConfirmationDialogOpen} onOpenChange={onOpenChange}
> >
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -323,3 +342,61 @@ function DeleteConfirmationDialog() {
</Dialog> </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>
)
}