From dbd1960b97feb8be507508453aa25fcaed392030 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 14 Sep 2025 10:59:49 +0000 Subject: [PATCH] feat: impl file delete Co-authored-by: Ona --- bun.lock | 8 ++ convex/_generated/api.d.ts | 2 + convex/files.ts | 12 +++ convex/model/directories.ts | 5 +- convex/model/files.ts | 13 +++ convex/schema.ts | 5 +- convex/users.ts | 2 +- package.json | 2 + src/components/ui/sonner.tsx | 33 +++--- src/files/file-table.tsx | 192 ++++++++++++++++++++++++++++++----- src/routes/__root.tsx | 32 +++--- src/routes/files.tsx | 22 ++-- 12 files changed, 259 insertions(+), 69 deletions(-) create mode 100644 convex/model/files.ts diff --git a/bun.lock b/bun.lock index c823cdd..ee53e76 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.41", "@tanstack/react-table": "^8.21.3", "@tanstack/router-devtools": "^1.131.42", @@ -18,6 +19,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "^1.27.0", + "jotai": "^2.14.0", "lucide-react": "^0.544.0", "next-themes": "^0.4.6", "react": "^19", @@ -180,6 +182,10 @@ "@tanstack/history": ["@tanstack/history@1.131.2", "", {}, "sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.87.4", "", {}, "sha512-uNsg6zMxraEPDVO2Bn+F3/ctHi+Zsk+MMpcN8h6P7ozqD088F6mFY5TfGM7zuyIrL7HKpDyu6QHfLWiDxh3cuw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.87.4", "", { "dependencies": { "@tanstack/query-core": "5.87.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-T5GT/1ZaNsUXf5I3RhcYuT17I4CPlbZgyLxc/ZGv7ciS6esytlbjb3DgUFO6c8JWYMDpdjSWInyGZUErgzqhcA=="], + "@tanstack/react-router": ["@tanstack/react-router@1.131.41", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/react-store": "^0.7.0", "@tanstack/router-core": "1.131.41", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-QEbTYpAosiD8e4qEZRr9aJipGSb8pQc+pfZwK6NCD2Tcxwu2oF6MVtwv0bIDLRpZP0VJMBpxXlTRISUDNMNqIA=="], "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.131.42", "", { "dependencies": { "@tanstack/router-devtools-core": "1.131.42" }, "peerDependencies": { "@tanstack/react-router": "^1.131.41", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-7pymFB1CCimRHot2Zp0ZekQjd1iN812V88n9NLPSeiv9sVRtRVIaLphJjDeudx1NNgkfSJPx2lOhz6K38cuZog=="], @@ -232,6 +238,8 @@ "isbot": ["isbot@5.1.30", "", {}, "sha512-3wVJEonAns1OETX83uWsk5IAne2S5zfDcntD2hbtU23LelSqNXzXs9zKjMPOLMzroCgIjCfjYAEHrd2D6FOkiA=="], + "jotai": ["jotai@2.14.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-JQkNkTnqjk1BlSUjHfXi+pGG/573bVN104gp6CymhrWDseZGDReTNniWrLhJ+zXbM6pH+82+UNJ2vwYQUkQMWQ=="], + "jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="], "lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 4288628..a319174 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -15,6 +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 users from "../users.js"; /** @@ -28,6 +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; users: typeof users; }>; export declare const api: FilterApi< diff --git a/convex/files.ts b/convex/files.ts index 6449a5f..472335c 100644 --- a/convex/files.ts +++ b/convex/files.ts @@ -62,3 +62,15 @@ export const saveFile = mutation({ }) }, }) + +export const moveToTrash = mutation({ + args: { + itemId: v.union(v.id("files"), v.id("directories")), + }, + handler: async (ctx, { itemId }) => { + await ctx.db.patch(itemId, { + deletedAt: new Date().toISOString(), + }) + return itemId + }, +}) diff --git a/convex/model/directories.ts b/convex/model/directories.ts index f64ca06..251c443 100644 --- a/convex/model/directories.ts +++ b/convex/model/directories.ts @@ -12,6 +12,7 @@ type File = { } export type DirectoryItem = Directory | File +export type DirectoryItemKind = DirectoryItem["kind"] export async function fetchContent( ctx: QueryCtx, @@ -20,7 +21,9 @@ export async function fetchContent( const [files, directories] = await Promise.all([ ctx.db .query("files") - .withIndex("byDirectoryId", (q) => q.eq("directoryId", directoryId)) + .withIndex("byDirectoryId", (q) => + q.eq("directoryId", directoryId).eq("deletedAt", undefined), + ) .collect(), ctx.db .query("directories") diff --git a/convex/model/files.ts b/convex/model/files.ts new file mode 100644 index 0000000..33cf157 --- /dev/null +++ b/convex/model/files.ts @@ -0,0 +1,13 @@ +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 c4ac6b9..73f5579 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -16,7 +16,10 @@ const schema = defineSchema({ size: v.number(), createdAt: v.string(), updatedAt: v.string(), - }).index("byDirectoryId", ["directoryId"]), + deletedAt: v.optional(v.string()), + }) + .index("byDirectoryId", ["directoryId", "deletedAt"]) + .index("byDeletedAt", ["deletedAt"]), directories: defineTable({ name: v.string(), parentId: v.optional(v.id("directories")), diff --git a/convex/users.ts b/convex/users.ts index ea8baa1..7974b9a 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -4,7 +4,7 @@ import { query } from "./_generated/server" export const getCurrentUser = query({ handler: async (ctx) => { return await ctx.db.get( - "j574n657f521n19v1stnr88ysd7qhbs1" as Id<"users">, + "jd7ampv4m200xr4yk2cfncccmh7qhj34" as Id<"users">, ) }, }) diff --git a/package.json b/package.json index 16d0cd8..e9f9d3f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.41", "@tanstack/react-table": "^8.21.3", "@tanstack/router-devtools": "^1.131.42", @@ -23,6 +24,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "^1.27.0", + "jotai": "^2.14.0", "lucide-react": "^0.544.0", "next-themes": "^0.4.6", "react": "^19", diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index cd62aff..4ad55dd 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,23 +1,24 @@ import { useTheme } from "next-themes" -import { Toaster as Sonner, ToasterProps } from "sonner" +import { Toaster as Sonner, type ToasterProps } from "sonner" const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { theme = "system" } = useTheme() - return ( - - ) + return ( + + ) } export { Toaster } diff --git a/src/files/file-table.tsx b/src/files/file-table.tsx index 1324036..6d253f2 100644 --- a/src/files/file-table.tsx +++ b/src/files/file-table.tsx @@ -1,9 +1,21 @@ import { api } from "@convex/_generated/api" -import type { DirectoryItem } from "@convex/model/directories" -import type { ColumnDef, Row } from "@tanstack/react-table" -import { useQuery } from "convex/react" +import type { Id } from "@convex/_generated/dataModel" +import type { + DirectoryItem, + DirectoryItemKind, +} from "@convex/model/directories" +import { useMutation } from "@tanstack/react-query" +import { + type ColumnDef, + flexRender, + getCoreRowModel, + type Row, + 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 { useState } from "react" +import { toast } from "sonner" import { DirectoryIcon } from "@/components/icons/directory-icon" import { Checkbox } from "@/components/ui/checkbox" import { @@ -12,7 +24,14 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" -import { DataTable } from "@/components/ui/data-table" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" function formatFileSize(bytes: number): string { if (bytes === 0) return "0 B" @@ -24,6 +43,16 @@ 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", @@ -89,14 +118,41 @@ const columns: ColumnDef[] = [ ] export function FileTable() { - const directory = useQuery(api.files.fetchDirectoryContent, {}) - const [selectedItem, setSelectedItem] = useState(null) + return ( + +
+ +
+
+ ) +} - if (!directory) { - return null - } +export function FileTableContextMenu({ + children, +}: { + children: React.ReactNode +}) { + 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) => { + setOptimisticDeletedItems((prev) => new Set([...prev, itemId])) + }, + onSuccess: (itemId) => { + setOptimisticDeletedItems((prev) => { + const newSet = new Set(prev) + newSet.delete(itemId) + return newSet + }) + toast.success("Moved to trash") + }, + }) const handleRename = () => { + const selectedItem = store.get(contextMenuTargeItemAtom) if (selectedItem) { console.log("Renaming:", selectedItem.doc.name) // TODO: Implement rename functionality @@ -104,27 +160,15 @@ export function FileTable() { } const handleDelete = () => { + const selectedItem = store.get(contextMenuTargeItemAtom) if (selectedItem) { - console.log("Deleting:", selectedItem.doc.name) - // TODO: Implement delete functionality + moveToTrashMutation.mutate(selectedItem.doc._id) } } - const handleRowContextMenu = (row: Row) => { - setSelectedItem(row.original) - } - return ( - -
- -
-
+ {children} @@ -139,6 +183,104 @@ export function FileTable() { ) } +export function FileTableContent() { + const directory = useQuery(api.files.fetchDirectoryContent, {}) + const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom) + const setContextMenuTargetItem = useSetAtom(contextMenuTargeItemAtom) + + const handleRowContextMenu = ( + row: Row, + event: React.MouseEvent, + ) => { + setContextMenuTargetItem(row.original) + } + + const table = useReactTable({ + data: directory || [], + columns, + getCoreRowModel: getCoreRowModel(), + enableRowSelection: true, + enableGlobalFilter: true, + globalFilterFn: (row, _columnId, _filterValue, _addMeta) => { + return !optimisticDeletedItems.has(row.original.doc._id) + }, + }) + + if (!directory) { + return null + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + { + handleRowContextMenu(row, e) + }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + + +
+
+ ) +} + +function NewItemRow() { + const itemBeingAdded = useAtomValue(itemBeingAddedAtom) + if (!itemBeingAdded) { + return null + } + return ( + + + + + + + ) +} + function FileNameCell({ initialName }: { initialName: string }) { return
{initialName}
} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 8d882ff..e84004e 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,9 +1,10 @@ -import { createRootRoute, Outlet } from "@tanstack/react-router" -import { TanStackRouterDevtools } from "@tanstack/router-devtools" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" 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 { TanStackRouterDevtools } from "@tanstack/router-devtools" import { ConvexProvider, ConvexReactClient } from "convex/react" export const Route = createRootRoute({ @@ -11,20 +12,23 @@ export const Route = createRootRoute({ }) const convexClient = new ConvexReactClient(process.env.BUN_PUBLIC_CONVEX_URL!) +const queryClient = new QueryClient() function RootLayout() { return ( - - -
- - - - -
- - -
-
+ + + +
+ + + + +
+ + +
+
+
) } diff --git a/src/routes/files.tsx b/src/routes/files.tsx index 48a758b..d0efc0f 100644 --- a/src/routes/files.tsx +++ b/src/routes/files.tsx @@ -1,14 +1,3 @@ -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 { type ChangeEvent, useRef, useState } from "react" -import { toast } from "sonner" import { DirectoryIcon } from "@/components/icons/directory-icon" import { TextFileIcon } from "@/components/icons/text-file-icon" import { @@ -26,6 +15,17 @@ import { } 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" export const Route = createFileRoute("/files")({ component: FilesPage,