mirror of
https://github.com/get-drexa/drive.git
synced 2025-12-01 05:51: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({
|
export const restoreItems = authenticatedMutation({
|
||||||
args: {
|
args: {
|
||||||
handles: v.array(VFileSystemHandle),
|
handles: v.array(VFileSystemHandle),
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user