From 59402f473fc392e12fc1075602d579785947b4e4 Mon Sep 17 00:00:00 2001 From: kenneth Date: Sun, 14 Sep 2025 18:12:29 +0000 Subject: [PATCH] feat: impl directory delete Co-authored-by: Ona --- convex/_generated/api.d.ts | 4 +- convex/files.ts | 20 +++- convex/model/directories.ts | 67 +++++++++++ convex/model/error.ts | 24 ++++ convex/model/files.ts | 13 --- convex/schema.ts | 8 +- src/components/ui/loading-spinner.tsx | 6 + src/files/file-table.tsx | 143 ++++++++++++++++++----- src/files/files-page.tsx | 161 ++++++++++++++++++++++++++ src/files/state.ts | 13 +++ src/lib/error.ts | 30 +++++ src/routes/__root.tsx | 16 ++- src/routes/files.tsx | 151 +----------------------- 13 files changed, 454 insertions(+), 202 deletions(-) create mode 100644 convex/model/error.ts delete mode 100644 convex/model/files.ts create mode 100644 src/components/ui/loading-spinner.tsx create mode 100644 src/files/files-page.tsx create mode 100644 src/files/state.ts create mode 100644 src/lib/error.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index a319174..b1ef5ab 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -15,7 +15,7 @@ import type { } from "convex/server"; import type * as files from "../files.js"; import type * as model_directories from "../model/directories.js"; -import type * as model_files from "../model/files.js"; +import type * as model_error from "../model/error.js"; import type * as users from "../users.js"; /** @@ -29,7 +29,7 @@ import type * as users from "../users.js"; declare const fullApi: ApiFromModules<{ files: typeof files; "model/directories": typeof model_directories; - "model/files": typeof model_files; + "model/error": typeof model_error; users: typeof users; }>; export declare const api: FilterApi< diff --git a/convex/files.ts b/convex/files.ts index 472335c..f24620e 100644 --- a/convex/files.ts +++ b/convex/files.ts @@ -65,12 +65,24 @@ export const saveFile = mutation({ export const moveToTrash = mutation({ args: { + kind: v.union(v.literal("file"), v.literal("directory")), itemId: v.union(v.id("files"), v.id("directories")), }, - handler: async (ctx, { itemId }) => { - await ctx.db.patch(itemId, { - deletedAt: new Date().toISOString(), - }) + handler: async (ctx, { itemId, kind }) => { + switch (kind) { + case "file": + await ctx.db.patch(itemId, { + deletedAt: new Date().toISOString(), + }) + break + case "directory": + await Directories.moveToTrashRecursive( + ctx, + itemId as Id<"directories">, + ) + break + } + return itemId }, }) diff --git a/convex/model/directories.ts b/convex/model/directories.ts index 251c443..eaf52c8 100644 --- a/convex/model/directories.ts +++ b/convex/model/directories.ts @@ -1,5 +1,6 @@ import type { Doc, Id } from "@convex/_generated/dataModel" import type { MutationCtx, QueryCtx } from "@convex/_generated/server" +import * as Err from "./error" type Directory = { kind: "directory" @@ -28,6 +29,7 @@ export async function fetchContent( ctx.db .query("directories") .withIndex("byParentId", (q) => q.eq("parentId", directoryId)) + .filter((q) => q.eq(q.field("deletedAt"), undefined)) .collect(), ]) @@ -46,6 +48,20 @@ export async function create( ctx: MutationCtx, { name, parentId }: { name: string; parentId?: Id<"directories"> }, ): Promise> { + const existing = await ctx.db + .query("directories") + .withIndex("uniqueDirectoryInDirectory", (q) => + q.eq("parentId", parentId).eq("name", name), + ) + .first() + + if (existing) { + throw Err.create( + Err.Code.DirectoryExists, + `Directory with name ${name} already exists in ${parentId ? `directory ${parentId}` : "root"}`, + ) + } + const now = new Date().toISOString() return await ctx.db.insert("directories", { name, @@ -54,3 +70,54 @@ export async function create( updatedAt: now, }) } + +export async function moveToTrashRecursive( + ctx: MutationCtx, + directoryId: Id<"directories">, +): Promise { + const now = new Date().toISOString() + + const filesToDelete: Id<"files">[] = [] + const directoriesToDelete: Id<"directories">[] = [] + + const directoryQueue: Id<"directories">[] = [directoryId] + + while (directoryQueue.length > 0) { + const currentDirectoryId = directoryQueue.shift()! + directoriesToDelete.push(currentDirectoryId) + + const files = await ctx.db + .query("files") + .withIndex("byDirectoryId", (q) => + q + .eq("directoryId", currentDirectoryId) + .eq("deletedAt", undefined), + ) + .collect() + + for (const file of files) { + filesToDelete.push(file._id) + } + + const subdirectories = await ctx.db + .query("directories") + .withIndex("byParentId", (q) => + q.eq("parentId", currentDirectoryId).eq("deletedAt", undefined), + ) + .collect() + + for (const subdirectory of subdirectories) { + directoryQueue.push(subdirectory._id) + } + } + + const filePatches = filesToDelete.map((fileId) => + ctx.db.patch(fileId, { deletedAt: now }), + ) + + const directoryPatches = directoriesToDelete.map((dirId) => + ctx.db.patch(dirId, { deletedAt: now }), + ) + + await Promise.all([...filePatches, ...directoryPatches]) +} diff --git a/convex/model/error.ts b/convex/model/error.ts new file mode 100644 index 0000000..4e6046e --- /dev/null +++ b/convex/model/error.ts @@ -0,0 +1,24 @@ +import { ConvexError } from "convex/values" + +export enum Code { + DirectoryExists = "DirectoryExists", + FileExists = "FileExists", + Internal = "Internal", +} + +export type ApplicationError = ConvexError<{ code: Code; message: string }> + +export function isApplicationError(error: unknown): error is ApplicationError { + return ( + error instanceof ConvexError && + "code" in error.data && + "message" in error.data + ) +} + +export function create(code: Code, message: string = "unknown error") { + return new ConvexError({ + code, + message, + }) +} diff --git a/convex/model/files.ts b/convex/model/files.ts deleted file mode 100644 index 33cf157..0000000 --- a/convex/model/files.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { v } from "convex/values" -import { mutation } from "../_generated/server" - -export const moveToTrash = mutation({ - args: { - fileId: v.id("files"), - }, - handler: async (ctx, { fileId }) => { - await ctx.db.patch(fileId, { - deletedAt: new Date().toISOString(), - }) - }, -}) diff --git a/convex/schema.ts b/convex/schema.ts index 73f5579..14d86f5 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -19,13 +19,17 @@ const schema = defineSchema({ deletedAt: v.optional(v.string()), }) .index("byDirectoryId", ["directoryId", "deletedAt"]) - .index("byDeletedAt", ["deletedAt"]), + .index("byDeletedAt", ["deletedAt"]) + .index("uniqueFileInDirectory", ["directoryId", "name", "deletedAt"]), directories: defineTable({ name: v.string(), parentId: v.optional(v.id("directories")), createdAt: v.string(), updatedAt: v.string(), - }).index("byParentId", ["parentId"]), + deletedAt: v.optional(v.string()), + }) + .index("byParentId", ["parentId", "deletedAt"]) + .index("uniqueDirectoryInDirectory", ["parentId", "name", "deletedAt"]), }) export default schema diff --git a/src/components/ui/loading-spinner.tsx b/src/components/ui/loading-spinner.tsx new file mode 100644 index 0000000..e63abd0 --- /dev/null +++ b/src/components/ui/loading-spinner.tsx @@ -0,0 +1,6 @@ +import { Loader2Icon } from "lucide-react" +import { cn } from "@/lib/utils" + +export function LoadingSpinner({ className }: { className?: string }) { + return +} diff --git a/src/files/file-table.tsx b/src/files/file-table.tsx index 6d253f2..d66d28a 100644 --- a/src/files/file-table.tsx +++ b/src/files/file-table.tsx @@ -1,9 +1,6 @@ import { api } from "@convex/_generated/api" import type { Id } from "@convex/_generated/dataModel" -import type { - DirectoryItem, - DirectoryItemKind, -} from "@convex/model/directories" +import type { DirectoryItem } from "@convex/model/directories" import { useMutation } from "@tanstack/react-query" import { type ColumnDef, @@ -13,8 +10,9 @@ import { useReactTable, } from "@tanstack/react-table" import { useMutation as useContextMutation, useQuery } from "convex/react" -import { atom, useAtomValue, useSetAtom, useStore } from "jotai" -import { TextCursorInputIcon, TrashIcon } from "lucide-react" +import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai" +import { CheckIcon, TextCursorInputIcon, TrashIcon, XIcon } from "lucide-react" +import { useEffect, useId, useRef } from "react" import { toast } from "sonner" import { DirectoryIcon } from "@/components/icons/directory-icon" import { Checkbox } from "@/components/ui/checkbox" @@ -32,6 +30,16 @@ import { TableHeader, TableRow, } from "@/components/ui/table" +import { TextFileIcon } from "../components/icons/text-file-icon" +import { Button } from "../components/ui/button" +import { LoadingSpinner } from "../components/ui/loading-spinner" +import { withDefaultOnError } from "../lib/error" +import { cn } from "../lib/utils" +import { + contextMenuTargeItemAtom, + newItemKindAtom, + optimisticDeletedItemsAtom, +} from "./state" function formatFileSize(bytes: number): string { if (bytes === 0) return "0 B" @@ -43,16 +51,6 @@ function formatFileSize(bytes: number): string { return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}` } -const contextMenuTargeItemAtom = atom(null) -const optimisticDeletedItemsAtom = atom( - new Set | Id<"directories">>(), -) - -const itemBeingAddedAtom = atom<{ - kind: DirectoryItemKind - name: string -} | null>(null) - const columns: ColumnDef[] = [ { id: "select", @@ -84,7 +82,7 @@ const columns: ColumnDef[] = [ return case "directory": return ( -
+
{row.original.doc.name}
@@ -134,11 +132,10 @@ export function FileTableContextMenu({ }) { const store = useStore() const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom) - const moveToTrash = useContextMutation(api.files.moveToTrash) - const moveToTrashMutation = useMutation({ - mutationFn: (itemId: Id<"files"> | Id<"directories">) => - moveToTrash({ itemId }), - onMutate: (itemId) => { + const moveToTrashMutation = useContextMutation(api.files.moveToTrash) + const { mutate: moveToTrash } = useMutation({ + mutationFn: moveToTrashMutation, + onMutate: ({ itemId }) => { setOptimisticDeletedItems((prev) => new Set([...prev, itemId])) }, onSuccess: (itemId) => { @@ -162,7 +159,10 @@ export function FileTableContextMenu({ const handleDelete = () => { const selectedItem = store.get(contextMenuTargeItemAtom) if (selectedItem) { - moveToTrashMutation.mutate(selectedItem.doc._id) + moveToTrash({ + kind: selectedItem.kind, + itemId: selectedItem.doc._id, + }) } } @@ -267,20 +267,105 @@ export function FileTableContent() { } function NewItemRow() { - const itemBeingAdded = useAtomValue(itemBeingAddedAtom) - if (!itemBeingAdded) { + const inputRef = useRef(null) + const newItemFormId = useId() + const [newItemKind, setNewItemKind] = useAtom(newItemKindAtom) + const { mutate: createDirectory, isPending } = useMutation({ + mutationFn: useContextMutation(api.files.createDirectory), + onSuccess: () => { + setNewItemKind(null) + }, + onError: withDefaultOnError(() => { + setTimeout(() => { + inputRef.current?.focus() + }, 1) + }), + }) + + useEffect(() => { + if (newItemKind) { + setTimeout(() => { + inputRef.current?.focus() + }, 1) + } + }, [newItemKind]) + + if (!newItemKind) { return null } + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const itemName = formData.get("itemName") as string + + if (itemName) { + createDirectory({ name: itemName }) + } else { + toast.error("Please enter a name.") + } + } + + const clearNewItemKind = () => { + // setItemBeingAdded(null) + setNewItemKind(null) + } + return ( - + - - + +
+ {isPending ? ( + + ) : ( + + )} +
+ +
+
+
+ + + {!isPending ? ( + <> + + + + ) : null}
) } function FileNameCell({ initialName }: { initialName: string }) { - return
{initialName}
+ return ( +
+ + {initialName} +
+ ) } diff --git a/src/files/files-page.tsx b/src/files/files-page.tsx new file mode 100644 index 0000000..7850b22 --- /dev/null +++ b/src/files/files-page.tsx @@ -0,0 +1,161 @@ +import { api } from "@convex/_generated/api" +import { + useMutation as useConvexMutation, + useQuery as useConvexQuery, +} from "convex/react" +import { useSetAtom } from "jotai" +import { + ChevronDownIcon, + Loader2Icon, + PlusIcon, + UploadCloudIcon, +} from "lucide-react" +import { type ChangeEvent, useRef, useState } from "react" +import { toast } from "sonner" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DirectoryIcon } from "../components/icons/directory-icon" +import { TextFileIcon } from "../components/icons/text-file-icon" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, +} from "../components/ui/breadcrumb" +import { Button } from "../components/ui/button" +import { SidebarTrigger } from "../components/ui/sidebar" +import { FileTable } from "./file-table" +import { newItemKindAtom } from "./state" + +export function FilesPage() { + return ( + <> +
+ + + + + Files + + + +
+ + +
+
+
+ +
+ + ) +} + +function UploadFileButton() { + const [isUploading, setIsUploading] = useState(false) + const currentUser = useConvexQuery(api.users.getCurrentUser) + const generateUploadUrl = useConvexMutation(api.files.generateUploadUrl) + const saveFile = useConvexMutation(api.files.saveFile) + const fileInputRef = useRef(null) + + const handleClick = () => { + fileInputRef.current?.click() + } + + const onFileUpload = async (e: ChangeEvent) => { + if (!currentUser?._id) { + return + } + + const file = e.target.files?.[0] + if (file) { + try { + setIsUploading(true) + + const uploadUrl = await generateUploadUrl() + const uploadResult = await fetch(uploadUrl, { + method: "POST", + body: file, + headers: { + "Content-Type": file.type, + }, + }) + const { storageId } = await uploadResult.json() + + await saveFile({ + storageId, + name: file.name, + size: file.size, + userId: currentUser._id, + }) + + toast.success("File uploaded successfully.", { + position: "top-center", + }) + } catch (error) { + console.error(error) + } finally { + setIsUploading(false) + } + } + } + + return ( + <> + + + + ) +} + +function NewDirectoryItemDropdown() { + const setNewItemKind = useSetAtom(newItemKindAtom) + + const addNewDirectory = () => { + setNewItemKind("directory") + } + + return ( + + + + + + + + Text file + + + + Directory + + + + ) +} diff --git a/src/files/state.ts b/src/files/state.ts new file mode 100644 index 0000000..20bf81c --- /dev/null +++ b/src/files/state.ts @@ -0,0 +1,13 @@ +import { atom } from "jotai" +import type { Id } from "../../convex/_generated/dataModel" +import type { + DirectoryItem, + DirectoryItemKind, +} from "../../convex/model/directories" + +export const contextMenuTargeItemAtom = atom(null) +export const optimisticDeletedItemsAtom = atom( + new Set | Id<"directories">>(), +) + +export const newItemKindAtom = atom(null) diff --git a/src/lib/error.ts b/src/lib/error.ts new file mode 100644 index 0000000..35c3861 --- /dev/null +++ b/src/lib/error.ts @@ -0,0 +1,30 @@ +import { toast } from "sonner" +import { Code as ErrorCode, isApplicationError } from "../../convex/model/error" + +const ERROR_MESSAGE = { + [ErrorCode.DirectoryExists]: "Directory already exists", + [ErrorCode.FileExists]: "File already exists", + [ErrorCode.Internal]: "Internal application error", +} as const + +export function formatError(error: unknown): string { + if (isApplicationError(error)) { + return ERROR_MESSAGE[error.data.code] + } + if (error instanceof Error) { + return error.message + } + return "Unknown error" +} + +export function defaultOnError(error: unknown) { + console.log(error) + toast.error(formatError(error)) +} + +export function withDefaultOnError(fn: (error: unknown) => void) { + return (error: unknown) => { + defaultOnError(error) + fn(error) + } +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index e84004e..981929a 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -3,16 +3,28 @@ import { Toaster } from "@/components/ui/sonner" import DashboardSidebar from "@/dashboard/dashboard-sidebar" import "@/styles/globals.css" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { Outlet, createRootRoute } from "@tanstack/react-router" +import { createRootRoute, Outlet } from "@tanstack/react-router" import { TanStackRouterDevtools } from "@tanstack/router-devtools" import { ConvexProvider, ConvexReactClient } from "convex/react" +import { toast } from "sonner" +import { formatError } from "@/lib/error" export const Route = createRootRoute({ component: RootLayout, }) const convexClient = new ConvexReactClient(process.env.BUN_PUBLIC_CONVEX_URL!) -const queryClient = new QueryClient() +const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + onError: (error) => { + console.log(error) + toast.error(formatError(error)) + }, + throwOnError: false, + }, + }, +}) function RootLayout() { return ( diff --git a/src/routes/files.tsx b/src/routes/files.tsx index d0efc0f..9ff5901 100644 --- a/src/routes/files.tsx +++ b/src/routes/files.tsx @@ -1,155 +1,6 @@ -import { DirectoryIcon } from "@/components/icons/directory-icon" -import { TextFileIcon } from "@/components/icons/text-file-icon" -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, -} from "@/components/ui/breadcrumb" -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { SidebarTrigger } from "@/components/ui/sidebar" -import { FileTable } from "@/files/file-table" -import { api } from "@convex/_generated/api" import { createFileRoute } from "@tanstack/react-router" -import { useMutation, useQuery } from "convex/react" -import { - ChevronDownIcon, - Loader2Icon, - PlusIcon, - UploadCloudIcon, -} from "lucide-react" -import { useRef, useState, type ChangeEvent } from "react" -import { toast } from "sonner" +import { FilesPage } from "@/files/files-page" export const Route = createFileRoute("/files")({ component: FilesPage, }) - -function FilesPage() { - return ( - <> -
- - - - - Files - - - -
- - -
-
-
- -
- - ) -} - -function UploadFileButton() { - const [isUploading, setIsUploading] = useState(false) - const currentUser = useQuery(api.users.getCurrentUser) - const generateUploadUrl = useMutation(api.files.generateUploadUrl) - const saveFile = useMutation(api.files.saveFile) - const fileInputRef = useRef(null) - - const handleClick = () => { - fileInputRef.current?.click() - } - - const onFileUpload = async (e: ChangeEvent) => { - if (!currentUser?._id) { - return - } - - const file = e.target.files?.[0] - if (file) { - try { - setIsUploading(true) - - const uploadUrl = await generateUploadUrl() - const uploadResult = await fetch(uploadUrl, { - method: "POST", - body: file, - headers: { - "Content-Type": file.type, - }, - }) - const { storageId } = await uploadResult.json() - - await saveFile({ - storageId, - name: file.name, - size: file.size, - userId: currentUser._id, - }) - - toast.success("File uploaded successfully.", { - position: "top-center", - }) - } catch (error) { - console.error(error) - } finally { - setIsUploading(false) - } - } - } - - return ( - <> - - - - ) -} - -function NewDirectoryItemDropdown() { - return ( - - - - - - - - Text file - - - - Directory - - - - ) -}