diff --git a/packages/convex/filesystem.ts b/packages/convex/filesystem.ts index 4681183..70e773e 100644 --- a/packages/convex/filesystem.ts +++ b/packages/convex/filesystem.ts @@ -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), diff --git a/packages/convex/model/error.ts b/packages/convex/model/error.ts index c67ef2e..0a0c2da 100644 --- a/packages/convex/model/error.ts +++ b/packages/convex/model/error.ts @@ -8,6 +8,7 @@ export enum Code { FileNotFound = "FileNotFound", Internal = "Internal", Unauthenticated = "Unauthenticated", + NotFound = "NotFound", } export type ApplicationErrorData = { code: Code; message?: string } diff --git a/packages/convex/model/filesystem.ts b/packages/convex/model/filesystem.ts index da3c8b6..f4f33f9 100644 --- a/packages/convex/model/filesystem.ts +++ b/packages/convex/model/filesystem.ts @@ -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> { - const existing = await ctx.db +export async function queryRootDirectory( + ctx: AuthenticatedQueryCtx, +): Promise | 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> { + 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 { // 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 { + 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)), + ], + }) +} diff --git a/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx b/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx index 6668720..58155ab 100644 --- a/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx +++ b/packages/web/src/routes/_authenticated/_sidebar-layout/trash.directories.$directoryId.tsx @@ -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([]) const fileDragInfoAtom = atom(null) -const isDeleteConfirmationDialogOpenAtom = atom(false) +const activeDialogAtom = atom(null) const openedFileAtom = atom | null>(null) const optimisticRemovedItemsAtom = atom( new Set | Id<"directories">>(), @@ -124,6 +130,7 @@ function RouteComponent() { + {(openedFile, setOpenedFile) => { @@ -141,9 +148,7 @@ function RouteComponent() { } function TableContextMenu({ children }: React.PropsWithChildren) { - const setIsDeleteConfirmationDialogOpen = useSetAtom( - isDeleteConfirmationDialogOpenAtom, - ) + const setActiveDialog = useSetAtom(activeDialogAtom) return ( @@ -153,7 +158,7 @@ function TableContextMenu({ children }: React.PropsWithChildren) { { - setIsDeleteConfirmationDialogOpen(true) + setActiveDialog(ActiveDialogKind.DeleteConfirmation) }} > @@ -223,9 +228,7 @@ function RestoreContextMenuItem() { } function EmptyTrashButton() { - const setIsDeleteConfirmationDialogOpen = useSetAtom( - isDeleteConfirmationDialogOpenAtom, - ) + const setActiveDialog = useSetAtom(activeDialogAtom) return ( ) } 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 ( @@ -323,3 +342,61 @@ function DeleteConfirmationDialog() { ) } + +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 ( + + + + Empty your trash? + + +

+ All items in the trash will be permanently deleted. They + will be IRRECOVERABLE. +

+ + + + + + + +
+
+ ) +}